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 { parseOnlineBackupKey } = require('./online-backup');
|
||||||
|
|
||||||
const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
|
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) {
|
function isObject(value) {
|
||||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(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) {
|
function normalizeTimestamp(value) {
|
||||||
const timestamp = new Date(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();
|
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({
|
function createOnlineBackupKeyring({
|
||||||
filePath,
|
filePath,
|
||||||
encryptField = secretStore.encryptField,
|
encryptField = secretStore.encryptField,
|
||||||
decryptField = secretStore.decryptField,
|
decryptField = secretStore.decryptField,
|
||||||
|
isEncrypted = secretStore.isEncrypted,
|
||||||
parseKey = parseOnlineBackupKey,
|
parseKey = parseOnlineBackupKey,
|
||||||
fsImpl = fs.promises
|
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();
|
let mutation = Promise.resolve();
|
||||||
|
|
||||||
function validateEntry(entry) {
|
function issueError(code) {
|
||||||
if (!isObject(entry)) return null;
|
return new OnlineBackupKeyringError(code);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readEntries(rejectInvalidEntries = false) {
|
function isTemporaryFileName(value) {
|
||||||
let contents;
|
return value.startsWith(temporaryPrefix)
|
||||||
try {
|
&& /^\d+\.[0-9a-f-]+\.(?:primary|recovery)\.tmp$/u.test(value.slice(temporaryPrefix.length));
|
||||||
contents = await fsImpl.readFile(filePath, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.code === 'ENOENT') return [];
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
let document;
|
||||||
try {
|
try {
|
||||||
document = JSON.parse(contents);
|
document = JSON.parse(contents);
|
||||||
} catch {
|
} 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)) {
|
if (!hasExactKeys(document, STORED_DOCUMENT_KEYS) || document.version !== 1 || !Array.isArray(document.keys)) {
|
||||||
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
|
throw issueError(KEYRING_ERROR_CODES.structure);
|
||||||
}
|
}
|
||||||
const entries = document.entries.map(validateEntry);
|
return document;
|
||||||
if (rejectInvalidEntries && entries.some((entry) => !entry)) {
|
|
||||||
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
|
|
||||||
}
|
|
||||||
return entries.filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeEntries(entries) {
|
async function readCandidate(candidatePath) {
|
||||||
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 });
|
|
||||||
try {
|
try {
|
||||||
await fsImpl.writeFile(temporaryPath, document, { encoding: 'utf8', flag: 'wx' });
|
const contents = await fsImpl.readFile(candidatePath, 'utf8');
|
||||||
await fsImpl.rename(temporaryPath, filePath);
|
return { status: 'valid', path: candidatePath, contents, document: parseDocument(contents) };
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
await fsImpl.unlink(temporaryPath);
|
const entries = await fsImpl.readdir(directory, { withFileTypes: true });
|
||||||
} catch (cleanupError) {
|
for (const entry of entries) {
|
||||||
if (cleanupError?.code !== 'ENOENT') throw cleanupError;
|
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) {
|
function serialize(operation) {
|
||||||
@@ -109,29 +268,134 @@ function createOnlineBackupKeyring({
|
|||||||
return next;
|
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() {
|
async function list() {
|
||||||
const entries = await readEntries();
|
const state = await readState();
|
||||||
const sanitized = entries
|
const entries = state.entries
|
||||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||||
.map(({ id, key, createdAt }) => Object.freeze({
|
.map(({ id, key, createdAt }) => Object.freeze({
|
||||||
id,
|
id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
||||||
createdAt
|
createdAt
|
||||||
}));
|
}));
|
||||||
return Object.freeze(sanitized);
|
return Object.freeze({
|
||||||
|
entries: Object.freeze(entries),
|
||||||
|
issues: Object.freeze([...state.issues])
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepare(key, createdAt) {
|
function prepare(key, createdAt) {
|
||||||
let parsed;
|
let parsed;
|
||||||
let encryptedKey;
|
|
||||||
try {
|
try {
|
||||||
parsed = parseKey(key);
|
parsed = parseKey(key);
|
||||||
encryptedKey = encryptField(key);
|
|
||||||
} catch {
|
} 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) {
|
let encryptedKey;
|
||||||
throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden');
|
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({
|
return Object.freeze({
|
||||||
id: parsed.id,
|
id: parsed.id,
|
||||||
@@ -143,29 +407,60 @@ function createOnlineBackupKeyring({
|
|||||||
function commit(entry) {
|
function commit(entry) {
|
||||||
return serialize(async () => {
|
return serialize(async () => {
|
||||||
const validated = validateEntry(entry);
|
const validated = validateEntry(entry);
|
||||||
if (!validated) throw new Error('Online-Sicherungsschlüssel ist ungültig');
|
if (!validated.entry) throw issueError(validated.issue);
|
||||||
const entries = await readEntries(true);
|
const state = await readState();
|
||||||
if (entries.some((current) => current.id === validated.id)) return;
|
const blockingIssue = firstBlockingIssue(state);
|
||||||
await writeEntries([...entries, validated]);
|
if (blockingIssue) throw issueError(blockingIssue);
|
||||||
});
|
if (state.entries.some(current => current.id === validated.entry.id)) return false;
|
||||||
}
|
await writeEntries([...state.entries, validated.entry]);
|
||||||
|
|
||||||
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);
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getKey(id) {
|
async function getKey(id) {
|
||||||
const entries = await readEntries();
|
const state = await readState();
|
||||||
return entries.find((entry) => entry.id === id)?.key ?? null;
|
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,
|
deleteOnlineBackup,
|
||||||
uploadOnlineBackup
|
uploadOnlineBackup
|
||||||
} = require('./online-backup');
|
} = require('./online-backup');
|
||||||
|
const { KEYRING_ERROR_CODES } = require('./online-backup-keyring');
|
||||||
|
|
||||||
const ERRORS = Object.freeze({
|
const ERRORS = Object.freeze({
|
||||||
list: 'Online-Sicherungen konnten nicht geladen werden',
|
list: 'Online-Sicherungen konnten nicht geladen werden',
|
||||||
@@ -11,6 +12,16 @@ const ERRORS = Object.freeze({
|
|||||||
delete: 'Online-Sicherung konnte nicht gelöscht werden',
|
delete: 'Online-Sicherung konnte nicht gelöscht werden',
|
||||||
notFound: 'Online-Sicherungsschlüssel wurde nicht gefunden'
|
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) {
|
function sanitizeEntry(entry) {
|
||||||
return {
|
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({
|
function createOnlineBackupManager({
|
||||||
keyring,
|
keyring,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
@@ -46,8 +63,12 @@ function createOnlineBackupManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function listEntries() {
|
async function listEntries() {
|
||||||
const entries = await keyring.list();
|
const snapshot = await keyring.list();
|
||||||
return entries.map(sanitizeEntry);
|
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() {
|
async function createTransaction() {
|
||||||
@@ -68,27 +89,39 @@ function createOnlineBackupManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTransaction(id) {
|
async function deleteTransaction(id) {
|
||||||
const key = await keyring.getKey(id);
|
const plan = await keyring.prepareRemove(id);
|
||||||
if (!key) return { ok: false, notFound: true, error: ERRORS.notFound };
|
if (!plan) return { ok: false, notFound: true, error: ERRORS.notFound };
|
||||||
const outcome = await deleteBackup(key);
|
const outcome = await deleteBackup(plan.key);
|
||||||
if (!outcome?.deleted && !outcome?.notFound) throw new Error(ERRORS.delete);
|
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 };
|
return { ok: true, removedId: id, notFound: outcome.notFound };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listManaged() {
|
async function listManaged() {
|
||||||
try {
|
try {
|
||||||
return { ok: true, entries: await listEntries() };
|
const snapshot = await listEntries();
|
||||||
} catch {
|
const issue = snapshot.issues[0];
|
||||||
return { ok: false, error: ERRORS.list };
|
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() {
|
async function createManaged() {
|
||||||
try {
|
try {
|
||||||
return await serialize(createTransaction);
|
return await serialize(createTransaction);
|
||||||
} catch {
|
} catch (error) {
|
||||||
return { ok: false, error: ERRORS.create };
|
return keyringFailure(error, ERRORS.create);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,16 +131,16 @@ function createOnlineBackupManager({
|
|||||||
if (!key) return { ok: false, notFound: true, error: ERRORS.notFound };
|
if (!key) return { ok: false, notFound: true, error: ERRORS.notFound };
|
||||||
await copyText(key);
|
await copyText(key);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch {
|
} catch (error) {
|
||||||
return { ok: false, error: ERRORS.copy };
|
return keyringFailure(error, ERRORS.copy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteManaged(id) {
|
async function deleteManaged(id) {
|
||||||
try {
|
try {
|
||||||
return await serialize(() => deleteTransaction(id));
|
return await serialize(() => deleteTransaction(id));
|
||||||
} catch {
|
} catch (error) {
|
||||||
return { ok: false, error: ERRORS.delete };
|
return keyringFailure(error, ERRORS.delete);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -35,7 +35,11 @@ function getSafeStorage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isEncrypted(value) {
|
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) {
|
function encryptField(value) {
|
||||||
@@ -55,7 +59,12 @@ function encryptField(value) {
|
|||||||
|
|
||||||
function decryptField(value) {
|
function decryptField(value) {
|
||||||
if (!value || typeof value !== 'string') return 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();
|
const ss = getSafeStorage();
|
||||||
if (!ss) {
|
if (!ss) {
|
||||||
throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar');
|
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');
|
const hint = document.getElementById('recentFilesHint');
|
||||||
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
|
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
|
||||||
renderManagedOnlineBackups();
|
renderManagedOnlineBackups();
|
||||||
|
renderManagedOnlineBackupRefreshIssue();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropdown options for "Add Account" modal: value -> label
|
// Dropdown options for "Add Account" modal: value -> label
|
||||||
@@ -64,11 +65,12 @@ let uploading = false;
|
|||||||
let healthCheckRunning = false;
|
let healthCheckRunning = false;
|
||||||
let managedOnlineBackups = [];
|
let managedOnlineBackups = [];
|
||||||
let managedOnlineBackupsAuthoritative = false;
|
let managedOnlineBackupsAuthoritative = false;
|
||||||
let managedOnlineBackupOperationGeneration = 0;
|
let managedOnlineBackupMutationGeneration = 0;
|
||||||
let managedOnlineBackupNavigationLoadGeneration = 0;
|
let managedOnlineBackupNavigationLoadGeneration = 0;
|
||||||
let managedOnlineBackupAuthoritativeLoadGeneration = 0;
|
let managedOnlineBackupAuthoritativeLoadGeneration = 0;
|
||||||
let managedOnlineBackupActiveOperations = 0;
|
let managedOnlineBackupActiveMutations = 0;
|
||||||
let onlineBackupStatusContextGeneration = 0;
|
let onlineBackupStatusContextGeneration = 0;
|
||||||
|
let managedOnlineBackupRefreshIssue = null;
|
||||||
const managedOnlineBackupOperationQueues = new Map();
|
const managedOnlineBackupOperationQueues = new Map();
|
||||||
|
|
||||||
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
|
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;
|
return ++onlineBackupStatusContextGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginManagedOnlineBackupOperation() {
|
function beginManagedOnlineBackupMutation() {
|
||||||
managedOnlineBackupActiveOperations++;
|
managedOnlineBackupActiveMutations++;
|
||||||
managedOnlineBackupOperationGeneration++;
|
managedOnlineBackupMutationGeneration++;
|
||||||
managedOnlineBackupNavigationLoadGeneration++;
|
managedOnlineBackupNavigationLoadGeneration++;
|
||||||
return beginOnlineBackupStatusContext();
|
return Object.freeze({
|
||||||
|
mutationGeneration: managedOnlineBackupMutationGeneration,
|
||||||
|
statusContext: beginOnlineBackupStatusContext()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function endManagedOnlineBackupOperation() {
|
function endManagedOnlineBackupMutation() {
|
||||||
managedOnlineBackupActiveOperations = Math.max(0, managedOnlineBackupActiveOperations - 1);
|
managedOnlineBackupActiveMutations = Math.max(0, managedOnlineBackupActiveMutations - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOnlineBackupStatus(message, state = '', statusContext = null) {
|
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() || '');
|
restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeManagedOnlineBackups(entries) {
|
function isCanonicalManagedOnlineBackupId(value) {
|
||||||
const sanitized = new Map();
|
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(value)) return false;
|
||||||
for (const entry of Array.isArray(entries) ? entries : []) {
|
try {
|
||||||
if (!entry || typeof entry !== 'object') continue;
|
const decoded = window.atob(`${value.replace(/-/g, '+').replace(/_/g, '/')}==`);
|
||||||
if (typeof entry.id !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(entry.id)) continue;
|
if (decoded.length !== 16) return false;
|
||||||
if (typeof entry.displayKey !== 'string' || !entry.displayKey || /MHU2-[A-Za-z0-9_-]{70}/.test(entry.displayKey)) continue;
|
const encoded = window.btoa(decoded).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
const createdAt = new Date(entry.createdAt);
|
return encoded === value;
|
||||||
if (Number.isNaN(createdAt.getTime())) continue;
|
} catch {
|
||||||
sanitized.set(entry.id, { id: entry.id, displayKey: entry.displayKey, createdAt: createdAt.toISOString() });
|
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);
|
managedOnlineBackups = normalizeManagedOnlineBackups(entries);
|
||||||
managedOnlineBackupsAuthoritative = true;
|
managedOnlineBackupsAuthoritative = true;
|
||||||
renderManagedOnlineBackups();
|
renderManagedOnlineBackups(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateManagedOnlineBackups() {
|
function upsertManagedOnlineBackup(entry) {
|
||||||
managedOnlineBackups = [];
|
const normalized = normalizeManagedOnlineBackups([entry]);
|
||||||
managedOnlineBackupsAuthoritative = false;
|
if (normalized.length !== 1) return false;
|
||||||
renderManagedOnlineBackups();
|
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');
|
const list = document.getElementById('managedOnlineBackupList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget;
|
||||||
const content = document.createDocumentFragment();
|
const content = document.createDocumentFragment();
|
||||||
if (!managedOnlineBackupsAuthoritative) {
|
if (!managedOnlineBackupsAuthoritative) {
|
||||||
list.replaceChildren(content);
|
list.replaceChildren(content);
|
||||||
@@ -2717,8 +2772,10 @@ function renderManagedOnlineBackups() {
|
|||||||
for (const entry of managedOnlineBackups) {
|
for (const entry of managedOnlineBackups) {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'online-backup-managed-row';
|
row.className = 'online-backup-managed-row';
|
||||||
|
row.dataset.managedOnlineBackupId = entry.id;
|
||||||
const key = document.createElement('span');
|
const key = document.createElement('span');
|
||||||
key.className = 'online-backup-managed-key';
|
key.className = 'online-backup-managed-key';
|
||||||
|
key.id = `managedOnlineBackupKey-${entry.id}`;
|
||||||
key.textContent = entry.displayKey;
|
key.textContent = entry.displayKey;
|
||||||
const created = document.createElement('span');
|
const created = document.createElement('span');
|
||||||
created.className = 'online-backup-managed-created';
|
created.className = 'online-backup-managed-created';
|
||||||
@@ -2729,10 +2786,16 @@ function renderManagedOnlineBackups() {
|
|||||||
copyButton.type = 'button';
|
copyButton.type = 'button';
|
||||||
copyButton.className = 'btn btn-secondary online-backup-copy-btn';
|
copyButton.className = 'btn btn-secondary online-backup-copy-btn';
|
||||||
copyButton.textContent = localizeUiText('Schlüssel kopieren');
|
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');
|
const deleteButton = document.createElement('button');
|
||||||
deleteButton.type = 'button';
|
deleteButton.type = 'button';
|
||||||
deleteButton.className = 'btn btn-danger online-backup-delete-btn';
|
deleteButton.className = 'btn btn-danger online-backup-delete-btn';
|
||||||
deleteButton.textContent = localizeUiText('Online-Backup löschen');
|
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);
|
const pending = managedOnlineBackupOperationQueues.has(entry.id);
|
||||||
copyButton.disabled = pending;
|
copyButton.disabled = pending;
|
||||||
deleteButton.disabled = pending;
|
deleteButton.disabled = pending;
|
||||||
@@ -2744,39 +2807,61 @@ function renderManagedOnlineBackups() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
list.replaceChildren(content);
|
list.replaceChildren(content);
|
||||||
|
restoreManagedOnlineBackupFocus(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadManagedOnlineBackups({ statusContext = null } = {}) {
|
function renderManagedOnlineBackupRefreshIssue() {
|
||||||
const authoritative = statusContext !== null;
|
const container = document.getElementById('managedOnlineBackupRefreshStatus');
|
||||||
const operationGeneration = managedOnlineBackupOperationGeneration;
|
const message = document.getElementById('managedOnlineBackupRefreshMessage');
|
||||||
const startedDuringOperation = managedOnlineBackupActiveOperations > 0;
|
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
|
const generation = authoritative
|
||||||
? ++managedOnlineBackupAuthoritativeLoadGeneration
|
? ++managedOnlineBackupAuthoritativeLoadGeneration
|
||||||
: ++managedOnlineBackupNavigationLoadGeneration;
|
: ++managedOnlineBackupNavigationLoadGeneration;
|
||||||
const loaderStatusContext = authoritative || !startedDuringOperation ? statusContext ?? beginOnlineBackupStatusContext() : null;
|
|
||||||
const mayUpdateStatus = loaderStatusContext !== null;
|
|
||||||
try {
|
try {
|
||||||
const result = await window.api.listManagedOnlineBackups();
|
const result = await window.api.listManagedOnlineBackups();
|
||||||
const stale = authoritative
|
const stale = authoritative
|
||||||
? generation !== managedOnlineBackupAuthoritativeLoadGeneration || operationGeneration !== managedOnlineBackupOperationGeneration
|
? generation !== managedOnlineBackupAuthoritativeLoadGeneration || mutationGeneration !== managedOnlineBackupMutationGeneration
|
||||||
: generation !== managedOnlineBackupNavigationLoadGeneration || operationGeneration !== managedOnlineBackupOperationGeneration || startedDuringOperation;
|
: generation !== managedOnlineBackupNavigationLoadGeneration || capturedMutationGeneration !== managedOnlineBackupMutationGeneration || startedDuringMutation;
|
||||||
if (stale) return false;
|
if (stale) return false;
|
||||||
if (!result?.ok || !Array.isArray(result.entries)) {
|
if (!result?.ok || !Array.isArray(result.entries)) {
|
||||||
invalidateManagedOnlineBackups();
|
setManagedOnlineBackupRefreshIssue(result?.error || 'Online-Sicherungen konnten nicht geladen werden', 'error');
|
||||||
if (mayUpdateStatus) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error', loaderStatusContext);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
replaceManagedOnlineBackups(result.entries);
|
replaceManagedOnlineBackups(result.entries);
|
||||||
if (mayUpdateStatus) setOnlineBackupStatus('', '', loaderStatusContext);
|
if (result.warning) setManagedOnlineBackupRefreshIssue(result.warning, 'warning');
|
||||||
|
else setManagedOnlineBackupRefreshIssue();
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
const current = authoritative
|
const current = authoritative
|
||||||
? generation === managedOnlineBackupAuthoritativeLoadGeneration && operationGeneration === managedOnlineBackupOperationGeneration
|
? generation === managedOnlineBackupAuthoritativeLoadGeneration && mutationGeneration === managedOnlineBackupMutationGeneration
|
||||||
: generation === managedOnlineBackupNavigationLoadGeneration && operationGeneration === managedOnlineBackupOperationGeneration && !startedDuringOperation;
|
: generation === managedOnlineBackupNavigationLoadGeneration && capturedMutationGeneration === managedOnlineBackupMutationGeneration && !startedDuringMutation;
|
||||||
if (current) {
|
if (current) setManagedOnlineBackupRefreshIssue('Online-Sicherungen konnten nicht geladen werden', 'error');
|
||||||
invalidateManagedOnlineBackups();
|
|
||||||
if (mayUpdateStatus) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error', loaderStatusContext);
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2785,15 +2870,21 @@ function hasManagedOnlineBackup(id) {
|
|||||||
return managedOnlineBackups.some(entry => entry.id === id);
|
return managedOnlineBackups.some(entry => entry.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueueManagedOnlineBackupOperation(id, action, operation) {
|
function enqueueManagedOnlineBackupOperation(id, action, operation, mutation = false) {
|
||||||
const statusContext = beginManagedOnlineBackupOperation();
|
|
||||||
const previous = managedOnlineBackupOperationQueues.get(id)?.promise || Promise.resolve();
|
const previous = managedOnlineBackupOperationQueues.get(id)?.promise || Promise.resolve();
|
||||||
const promise = previous.catch(() => {}).then(() => operation(statusContext));
|
const state = { action, promise: null };
|
||||||
const state = { action, promise };
|
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);
|
managedOnlineBackupOperationQueues.set(id, state);
|
||||||
renderManagedOnlineBackups();
|
renderManagedOnlineBackups();
|
||||||
return promise.finally(() => {
|
return promise.finally(() => {
|
||||||
endManagedOnlineBackupOperation();
|
|
||||||
if (managedOnlineBackupOperationQueues.get(id) !== state) return;
|
if (managedOnlineBackupOperationQueues.get(id) !== state) return;
|
||||||
managedOnlineBackupOperationQueues.delete(id);
|
managedOnlineBackupOperationQueues.delete(id);
|
||||||
renderManagedOnlineBackups();
|
renderManagedOnlineBackups();
|
||||||
@@ -2803,18 +2894,20 @@ function enqueueManagedOnlineBackupOperation(id, action, operation) {
|
|||||||
async function copyManagedOnlineBackup(entry) {
|
async function copyManagedOnlineBackup(entry) {
|
||||||
const id = entry.id;
|
const id = entry.id;
|
||||||
if (!hasManagedOnlineBackup(id) || managedOnlineBackupOperationQueues.has(id)) return;
|
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 {
|
try {
|
||||||
const result = await window.api.copyManagedOnlineBackup(id);
|
const result = await window.api.copyManagedOnlineBackup(id);
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error', statusContext);
|
showCopyToast(result?.error || 'Online-Sicherung konnte nicht kopiert werden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showCopyToast('Online-Schlüssel kopiert');
|
showCopyToast('Online-Schlüssel kopiert');
|
||||||
} catch {
|
} catch {
|
||||||
setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error', statusContext);
|
showCopyToast('Online-Sicherung konnte nicht kopiert werden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
renderManagedOnlineBackups(focusTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteManagedOnlineBackup(entry) {
|
async function deleteManagedOnlineBackup(entry) {
|
||||||
@@ -2827,32 +2920,35 @@ async function deleteManagedOnlineBackup(entry) {
|
|||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
if (!hasManagedOnlineBackup(id)) {
|
if (!hasManagedOnlineBackup(id)) {
|
||||||
const statusContext = beginManagedOnlineBackupOperation();
|
const statusContext = beginOnlineBackupStatusContext();
|
||||||
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
|
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
|
||||||
endManagedOnlineBackupOperation();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (managedOnlineBackupOperationQueues.get(id)?.action === 'delete') {
|
if (managedOnlineBackupOperationQueues.get(id)?.action === 'delete') {
|
||||||
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error');
|
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error');
|
||||||
return;
|
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)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await window.api.deleteManagedOnlineBackup(id);
|
const result = await window.api.deleteManagedOnlineBackup(id);
|
||||||
if (!result?.ok || result.removedId !== 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;
|
return;
|
||||||
}
|
}
|
||||||
if (!await loadManagedOnlineBackups({ statusContext })) return;
|
removed = removeManagedOnlineBackup(id, { id, action: 'delete', fallbackIndex });
|
||||||
setOnlineBackupStatus('Schlüssel gelöscht', 'success', statusContext);
|
await loadManagedOnlineBackups({ mutationGeneration: authority.mutationGeneration });
|
||||||
|
setOnlineBackupStatus('Schlüssel gelöscht', 'success', authority.statusContext);
|
||||||
} catch {
|
} 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() {
|
async function doOnlineBackupCreate() {
|
||||||
@@ -2863,29 +2959,29 @@ async function doOnlineBackupCreate() {
|
|||||||
openOnlineBackupView();
|
openOnlineBackupView();
|
||||||
} catch {
|
} catch {
|
||||||
openOnlineBackupView();
|
openOnlineBackupView();
|
||||||
const statusContext = beginManagedOnlineBackupOperation();
|
const statusContext = beginOnlineBackupStatusContext();
|
||||||
setOnlineBackupStatus('Nicht alle Einstellungen konnten gespeichert werden', 'error', statusContext);
|
setOnlineBackupStatus('Nicht alle Einstellungen konnten gespeichert werden', 'error', statusContext);
|
||||||
endManagedOnlineBackupOperation();
|
|
||||||
doOnlineBackupCreate.busy = false;
|
doOnlineBackupCreate.busy = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const statusContext = beginManagedOnlineBackupOperation();
|
const authority = beginManagedOnlineBackupMutation();
|
||||||
const createButton = document.getElementById('createOnlineBackupBtn');
|
const createButton = document.getElementById('createOnlineBackupBtn');
|
||||||
if (createButton) createButton.disabled = true;
|
if (createButton) createButton.disabled = true;
|
||||||
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', statusContext);
|
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', authority.statusContext);
|
||||||
try {
|
try {
|
||||||
const result = await window.api.createManagedOnlineBackup();
|
const result = await window.api.createManagedOnlineBackup();
|
||||||
if (!result?.ok) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!await loadManagedOnlineBackups({ statusContext })) return;
|
if (!upsertManagedOnlineBackup(result.entry)) setManagedOnlineBackupRefreshIssue('Online-Sicherungen konnten nicht geladen werden', 'error');
|
||||||
setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success', statusContext);
|
await loadManagedOnlineBackups({ mutationGeneration: authority.mutationGeneration });
|
||||||
|
setOnlineBackupStatus('Neuer Schlüssel erstellt.', 'success', authority.statusContext);
|
||||||
showCopyToast('Online-Schlüssel erstellt');
|
showCopyToast('Online-Schlüssel erstellt');
|
||||||
} catch {
|
} catch {
|
||||||
setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', statusContext);
|
setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext);
|
||||||
} finally {
|
} finally {
|
||||||
endManagedOnlineBackupOperation();
|
endManagedOnlineBackupMutation();
|
||||||
if (createButton?.isConnected) createButton.disabled = false;
|
if (createButton?.isConnected) createButton.disabled = false;
|
||||||
doOnlineBackupCreate.busy = false;
|
doOnlineBackupCreate.busy = false;
|
||||||
}
|
}
|
||||||
@@ -5088,6 +5184,10 @@ function renderSettings() {
|
|||||||
<section class="online-backup-managed" aria-labelledby="managedOnlineBackupHeading">
|
<section class="online-backup-managed" aria-labelledby="managedOnlineBackupHeading">
|
||||||
<h4 id="managedOnlineBackupHeading">Auf diesem Gerät erstellt</h4>
|
<h4 id="managedOnlineBackupHeading">Auf diesem Gerät erstellt</h4>
|
||||||
<div class="online-backup-managed-list" id="managedOnlineBackupList"></div>
|
<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>
|
</section>
|
||||||
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
|
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
|
||||||
<footer class="online-backup-footer">
|
<footer class="online-backup-footer">
|
||||||
@@ -5401,6 +5501,7 @@ function renderSettings() {
|
|||||||
});
|
});
|
||||||
document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate());
|
document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate());
|
||||||
renderManagedOnlineBackups();
|
renderManagedOnlineBackups();
|
||||||
|
renderManagedOnlineBackupRefreshIssue();
|
||||||
document.getElementById('onlineBackupKeyInput').addEventListener('input', (event) => {
|
document.getElementById('onlineBackupKeyInput').addEventListener('input', (event) => {
|
||||||
const valid = /^MHU2-[A-Za-z0-9_-]{70}$/.test(event.target.value.trim());
|
const valid = /^MHU2-[A-Za-z0-9_-]{70}$/.test(event.target.value.trim());
|
||||||
document.getElementById('restoreOnlineBackupBtn').disabled = document.getElementById('restoreOnlineBackupBtn').dataset.busy === 'true' || !valid;
|
document.getElementById('restoreOnlineBackupBtn').disabled = document.getElementById('restoreOnlineBackupBtn').dataset.busy === 'true' || !valid;
|
||||||
|
|||||||
+9
-1
@@ -313,6 +313,7 @@
|
|||||||
['Online-Backup löschen', 'Delete online backup'],
|
['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.'],
|
['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'],
|
['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.'],
|
['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'],
|
['Dein neuer Schlüssel', 'Your new key'],
|
||||||
['Neuer Schlüssel', 'Your new key'],
|
['Neuer Schlüssel', 'Your new key'],
|
||||||
@@ -459,7 +460,7 @@
|
|||||||
['Log-Datei', 'Log file'],
|
['Log-Datei', 'Log file'],
|
||||||
['MB/s · 0 = unbegrenzt', 'MB/s · 0 = unlimited'],
|
['MB/s · 0 = unbegrenzt', 'MB/s · 0 = unlimited'],
|
||||||
['Log-Pfad automatisch auf funktionierenden Ordner gesetzt', 'Log path automatically changed to a writable folder'],
|
['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'],
|
['Nicht alle Einstellungen konnten gespeichert werden', 'Not all settings could be saved'],
|
||||||
['Online-Schlüssel erstellt', 'Online key created'],
|
['Online-Schlüssel erstellt', 'Online key created'],
|
||||||
['Online-Schlüssel kopiert', 'Online key copied'],
|
['Online-Schlüssel kopiert', 'Online key copied'],
|
||||||
@@ -542,6 +543,13 @@
|
|||||||
['Online-Sicherungs-ID ist ungültig', 'Online backup ID is invalid'],
|
['Online-Sicherungs-ID ist ungültig', 'Online backup ID is invalid'],
|
||||||
['Ungültiger Erstellungszeitpunkt', 'Invalid creation timestamp'],
|
['Ungültiger Erstellungszeitpunkt', 'Invalid creation timestamp'],
|
||||||
['Gespeicherter Online-Schlüsselbund ist ungültig', 'Stored online keyring is invalid'],
|
['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'],
|
['Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden', 'Online backup key could not be prepared securely'],
|
||||||
['Geschützter Schlüssel', 'Protected key'],
|
['Geschützter Schlüssel', 'Protected key'],
|
||||||
['Backup hat eine ungültige Struktur', 'The backup has an invalid structure'],
|
['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;
|
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 {
|
.online-backup-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -3694,6 +3716,11 @@ input[type="checkbox"] {
|
|||||||
.online-backup-footer .btn {
|
.online-backup-footer .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.online-backup-refresh-status {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings-view {
|
#settings-view {
|
||||||
|
|||||||
+9
-1
@@ -31,7 +31,15 @@ test('translates managed online backup controls in both directions', () => {
|
|||||||
['Online-Backup löschen', 'Delete online backup'],
|
['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.'],
|
['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'],
|
['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) {
|
for (const [german, english] of pairs) {
|
||||||
|
|||||||
+357
-112
@@ -1,28 +1,51 @@
|
|||||||
|
const crypto = require('node:crypto');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { afterEach, describe, it } = require('node:test');
|
const { afterEach, describe, it } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const secretStore = require('../lib/secret-store');
|
||||||
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
|
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
|
||||||
|
|
||||||
const directories = [];
|
const directories = [];
|
||||||
|
const timestamp = '2026-08-22T10:00:00.000Z';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
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 = {}) {
|
function fixture(options = {}) {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-keyring-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-keyring-'));
|
||||||
directories.push(directory);
|
directories.push(directory);
|
||||||
const filePath = path.join(directory, 'online-backup-keyring.json');
|
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');
|
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
|
||||||
return {
|
return {
|
||||||
directory,
|
directory,
|
||||||
filePath,
|
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;
|
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', () => {
|
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 { filePath, keyring } = fixture();
|
||||||
const key = validKey();
|
const key = validKey();
|
||||||
const prepared = keyring.prepare(key, '2026-08-22T10:00:00.000Z');
|
const prepared = keyring.prepare(key, timestamp);
|
||||||
|
|
||||||
await keyring.commit(prepared);
|
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.equal(fs.readFileSync(filePath, 'utf8').includes(key), false);
|
||||||
assert.deepEqual(listed, [{
|
assert.deepEqual(snapshot.issues, []);
|
||||||
|
assert.deepEqual(snapshot.entries, [{
|
||||||
id: parseOnlineBackupKey(key).id,
|
id: parseOnlineBackupKey(key).id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
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(snapshot), true);
|
||||||
assert.equal(Object.isFrozen(listed[0]), true);
|
assert.equal(Object.isFrozen(snapshot.entries), true);
|
||||||
|
assert.equal(Object.isFrozen(snapshot.entries[0]), true);
|
||||||
assert.equal(await keyring.getKey(prepared.id), key);
|
assert.equal(await keyring.getKey(prepared.id), key);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws during prepare without writing when safe encryption is unavailable or fails', () => {
|
it('requires a canonical encrypted envelope before decrypting stored values', async () => {
|
||||||
for (const failure of [
|
|
||||||
() => { throw new Error('Sicherer Zugangsdaten-Speicher ist nicht verfügbar'); },
|
|
||||||
(value) => { throw new Error(`Verschlüsselung fehlgeschlagen: ${value}`); }
|
|
||||||
]) {
|
|
||||||
const { filePath, keyring } = fixture({ encryptField: failure });
|
|
||||||
const key = validKey();
|
const key = validKey();
|
||||||
assert.throws(() => keyring.prepare(key, '2026-08-22T10:00:00.000Z'), (error) => !error.message.includes(key));
|
const id = parseOnlineBackupKey(key).id;
|
||||||
assert.equal(fs.existsSync(filePath), false);
|
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 () => {
|
it('rejects plaintext even when the real secret store would pass legacy values through', async () => {
|
||||||
const { keyring } = fixture();
|
const { filePath, keyring } = fixture({
|
||||||
const older = validKey();
|
decryptField: secretStore.decryptField,
|
||||||
const newer = validKey();
|
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'));
|
assert.equal(secretStore.decryptField(key), key);
|
||||||
await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z'));
|
const snapshot = await keyring.list();
|
||||||
|
assert.deepEqual(snapshot.entries, []);
|
||||||
assert.deepEqual((await keyring.list()).map((entry) => entry.id), [
|
assert.deepEqual(snapshot.issues, ['KEYRING_STRUCTURE_INVALID']);
|
||||||
parseOnlineBackupKey(newer).id,
|
await assert.rejects(
|
||||||
parseOnlineBackupKey(older).id
|
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;
|
let encryptionCount = 0;
|
||||||
const encryptField = (value) => `enc:v1:${++encryptionCount}:${Buffer.from(value).toString('base64')}`;
|
const encryptField = value => `enc:v1:${Buffer.from(`${++encryptionCount}:${value}`).toString('base64')}`;
|
||||||
const decryptField = (value) => Buffer.from(value.split(':').slice(3).join(':'), 'base64').toString('utf8');
|
const decryptField = value => Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8').replace(/^\d+:/u, '');
|
||||||
const { filePath, keyring } = fixture({ encryptField, decryptField });
|
const { filePath, keyring } = fixture({ encryptField, decryptField });
|
||||||
const key = validKey();
|
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');
|
const duplicate = keyring.prepare(key, '2026-08-22T12:00:00.000Z');
|
||||||
|
|
||||||
await keyring.commit(first);
|
await keyring.commit(first);
|
||||||
@@ -90,103 +255,183 @@ describe('encrypted online backup keyring', () => {
|
|||||||
await keyring.commit(duplicate);
|
await keyring.commit(duplicate);
|
||||||
|
|
||||||
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
|
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
|
||||||
assert.deepEqual(await keyring.list(), [{
|
assert.deepEqual((await keyring.list()).entries, [{
|
||||||
id: parseOnlineBackupKey(key).id,
|
id: parseOnlineBackupKey(key).id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
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 () => {
|
it('sorts newer entries first', async () => {
|
||||||
const { directory, filePath, keyring } = fixture();
|
const { keyring } = fixture();
|
||||||
const key = validKey();
|
const older = validKey();
|
||||||
const prepared = keyring.prepare(key, '2026-08-22T10:00:00.000Z');
|
const newer = validKey();
|
||||||
await keyring.commit(prepared);
|
|
||||||
|
|
||||||
assert.equal(await keyring.remove(prepared.id), true);
|
await keyring.commit(keyring.prepare(older, timestamp));
|
||||||
assert.equal(await keyring.remove(prepared.id), false);
|
await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z'));
|
||||||
assert.deepEqual(await keyring.list(), []);
|
|
||||||
assert.deepEqual(fs.readdirSync(directory), [path.basename(filePath)]);
|
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 () => {
|
it('syncs complete temporary files before atomic replacement and syncs directory metadata', async () => {
|
||||||
const { filePath, keyring } = fixture();
|
const events = [];
|
||||||
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');
|
|
||||||
const fsImpl = {
|
const fsImpl = {
|
||||||
...fs.promises,
|
...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 { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
|
||||||
const keyring = createOnlineBackupKeyring({
|
const keyring = createOnlineBackupKeyring({
|
||||||
filePath: firstFixture.filePath,
|
filePath: first.filePath,
|
||||||
encryptField: (value) => `enc:v1:${Buffer.from(value).toString('base64')}`,
|
encryptField: encrypt,
|
||||||
decryptField: (value) => Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8'),
|
decryptField: decrypt,
|
||||||
|
isEncrypted: isCanonicalEnvelope,
|
||||||
fsImpl
|
fsImpl
|
||||||
});
|
});
|
||||||
const secondKey = validKey();
|
|
||||||
|
|
||||||
await assert.rejects(keyring.commit(keyring.prepare(secondKey, '2026-08-22T11:00:00.000Z')), /rename failed/);
|
await assert.rejects(keyring.commit(keyring.prepare(validKey(), '2026-08-22T11:00:00.000Z')), /rename failed/u);
|
||||||
assert.equal(fs.readFileSync(firstFixture.filePath, 'utf8'), original);
|
assert.equal(fs.readFileSync(first.filePath, 'utf8'), original);
|
||||||
assert.deepEqual(fs.readdirSync(firstFixture.directory), [path.basename(firstFixture.filePath)]);
|
assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.tmp')), false);
|
||||||
assert.equal(await firstFixture.keyring.getKey(parseOnlineBackupKey(firstKey).id), firstKey);
|
assert.equal(await first.keyring.getKey(parseOnlineBackupKey(firstKey).id), firstKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never includes complete plaintext keys in persisted files or mutation errors', async () => {
|
it('types invalid documents and never includes plaintext or ciphertext in errors', async () => {
|
||||||
const { filePath, keyring } = fixture();
|
const { filePath, backupPath, keyring } = fixture();
|
||||||
const firstKey = validKey();
|
const key = validKey();
|
||||||
const secondKey = validKey();
|
fs.writeFileSync(filePath, `{invalid-${key}`);
|
||||||
await keyring.commit(keyring.prepare(firstKey, '2026-08-22T10:00:00.000Z'));
|
fs.writeFileSync(backupPath, '{invalid-backup');
|
||||||
fs.writeFileSync(filePath, '{invalid-json');
|
|
||||||
|
|
||||||
let error;
|
await assert.rejects(
|
||||||
try {
|
keyring.list(),
|
||||||
await keyring.commit(keyring.prepare(secondKey, '2026-08-22T11:00:00.000Z'));
|
error => error.code === 'KEYRING_STRUCTURE_INVALID'
|
||||||
} catch (caught) {
|
&& !error.message.includes(key)
|
||||||
error = caught;
|
&& !error.message.includes('invalid-backup')
|
||||||
}
|
);
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 assert = require('node:assert/strict');
|
||||||
|
|
||||||
const { createOnlineBackupManager } = require('../lib/online-backup-manager');
|
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 key = `MHU2-${'K'.repeat(70)}`;
|
||||||
const id = 'managed-backup-id';
|
const id = 'managed-backup-id';
|
||||||
const record = { id, blob: 'encrypted-blob', deleteVerifier: 'delete-verifier' };
|
const record = { id, blob: 'encrypted-blob', deleteVerifier: 'delete-verifier' };
|
||||||
const settings = { globalSettings: { alwaysOnTop: true } };
|
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 = {}) {
|
function createFixture(overrides = {}) {
|
||||||
const events = [];
|
const events = [];
|
||||||
@@ -22,10 +33,15 @@ function createFixture(overrides = {}) {
|
|||||||
}
|
}
|
||||||
let createdAt;
|
let createdAt;
|
||||||
let createArguments;
|
let createArguments;
|
||||||
|
const removalPlans = new WeakMap();
|
||||||
const keyring = {
|
const keyring = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
events.push('list');
|
events.push('list');
|
||||||
return [...entries.values()];
|
if (overrides.listError) throw overrides.listError;
|
||||||
|
return {
|
||||||
|
entries: [...entries.values()],
|
||||||
|
issues: overrides.listIssues || []
|
||||||
|
};
|
||||||
},
|
},
|
||||||
prepare: (value, timestamp) => {
|
prepare: (value, timestamp) => {
|
||||||
events.push('prepare');
|
events.push('prepare');
|
||||||
@@ -43,15 +59,29 @@ function createFixture(overrides = {}) {
|
|||||||
createdAt: entry.createdAt
|
createdAt: entry.createdAt
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
remove: async (entryId) => {
|
|
||||||
events.push('remove');
|
|
||||||
const existed = state.delete(entryId);
|
|
||||||
entries.delete(entryId);
|
|
||||||
return existed;
|
|
||||||
},
|
|
||||||
getKey: async (entryId) => {
|
getKey: async (entryId) => {
|
||||||
events.push('getKey');
|
events.push('getKey');
|
||||||
|
if (overrides.getKeyError) throw overrides.getKeyError;
|
||||||
return state.get(entryId) || null;
|
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({
|
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', () => {
|
describe('transactional online backup manager', () => {
|
||||||
it('creates in prepare, upload, commit order and returns only the sanitized entry', async () => {
|
it('creates in prepare, upload, commit order and returns only the sanitized entry', async () => {
|
||||||
const fixture = createFixture({ initialKey: null });
|
const fixture = createFixture({ initialKey: null });
|
||||||
@@ -191,12 +270,28 @@ describe('transactional online backup manager', () => {
|
|||||||
assert.equal(JSON.stringify(result).includes(key), false);
|
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 () => {
|
it('removes the local entry after a successful remote deletion', async () => {
|
||||||
const fixture = createFixture();
|
const fixture = createFixture();
|
||||||
|
|
||||||
const result = await fixture.manager.deleteManaged(id);
|
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.deepEqual(result, { ok: true, removedId: id, notFound: false });
|
||||||
assert.equal(fixture.state.has(id), false);
|
assert.equal(fixture.state.has(id), false);
|
||||||
assert.equal(JSON.stringify(result).includes(key), 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);
|
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.deepEqual(result, { ok: true, removedId: id, notFound: true });
|
||||||
assert.equal(fixture.state.has(id), false);
|
assert.equal(fixture.state.has(id), false);
|
||||||
});
|
});
|
||||||
@@ -217,19 +312,73 @@ describe('transactional online backup manager', () => {
|
|||||||
|
|
||||||
const result = await fixture.manager.deleteManaged(id);
|
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.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht gelöscht werden' });
|
||||||
assert.equal(fixture.state.has(id), true);
|
assert.equal(fixture.state.has(id), true);
|
||||||
assert.equal(JSON.stringify(result).includes(key), false);
|
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 () => {
|
it('does not call copy or remote deletion dependencies for unknown IDs', async () => {
|
||||||
const fixture = createFixture({ initialKey: null });
|
const fixture = createFixture({ initialKey: null });
|
||||||
|
|
||||||
const copied = await fixture.manager.copyManaged('unknown-id');
|
const copied = await fixture.manager.copyManaged('unknown-id');
|
||||||
const deleted = await fixture.manager.deleteManaged('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, {
|
assert.deepEqual(copied, {
|
||||||
ok: false,
|
ok: false,
|
||||||
notFound: true,
|
notFound: true,
|
||||||
@@ -282,9 +431,9 @@ describe('transactional online backup manager', () => {
|
|||||||
'upload:start',
|
'upload:start',
|
||||||
'upload:end',
|
'upload:end',
|
||||||
'commit',
|
'commit',
|
||||||
'getKey',
|
'prepareRemove',
|
||||||
`delete:${key}`,
|
`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.deepEqual(failed, { ok: false, error: 'Online-Sicherungen konnten nicht geladen werden' });
|
||||||
assert.equal(JSON.stringify(failed).includes(key), false);
|
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', () => {
|
test('refuses plaintext storage by default when secure storage is unavailable', () => {
|
||||||
withSecretStore(null, secretStore => {
|
withSecretStore(null, secretStore => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -94,7 +105,7 @@ test('throws an identifiable error when decryption fails', () => {
|
|||||||
const failure = new Error('decryption failed');
|
const failure = new Error('decryption failed');
|
||||||
withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => {
|
withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => secretStore.decryptField('enc:v1:invalid'),
|
() => secretStore.decryptField('enc:v1:aW52YWxpZA=='),
|
||||||
error => error instanceof secretStore.SecretStoreError
|
error => error instanceof secretStore.SecretStoreError
|
||||||
&& error.code === 'SECRET_STORE_DECRYPT_FAILED'
|
&& error.code === 'SECRET_STORE_DECRYPT_FAILED'
|
||||||
&& error.cause === failure
|
&& 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', () => {
|
test('keeps legacy plaintext values readable without secure storage', () => {
|
||||||
withSecretStore(null, secretStore => {
|
withSecretStore(null, secretStore => {
|
||||||
assert.equal(secretStore.decryptField('legacy-secret'), 'legacy-secret');
|
assert.equal(secretStore.decryptField('legacy-secret'), 'legacy-secret');
|
||||||
|
|||||||
+385
-191
@@ -1,7 +1,7 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { EventEmitter } = require('node:events');
|
const { EventEmitter } = require('node:events');
|
||||||
const { execFileSync } = require('node:child_process');
|
const { execFileSync, spawnSync } = require('node:child_process');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
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, `
|
fs.writeFileSync(preloadPath, `
|
||||||
const { contextBridge } = require('electron');
|
const { contextBridge } = require('electron');
|
||||||
const managedOnlineBackupProbeCalls = [];
|
const managedOnlineBackupProbeCalls = [];
|
||||||
|
const managedOnlineBackupIds = {
|
||||||
|
a: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||||
|
b: 'AQEBAQEBAQEBAQEBAQEBAQ',
|
||||||
|
c: 'AgICAgICAgICAgICAgICAg',
|
||||||
|
d: 'AwMDAwMDAwMDAwMDAwMDAw'
|
||||||
|
};
|
||||||
const managedOnlineBackupListResponses = [
|
const managedOnlineBackupListResponses = [
|
||||||
{ ok: true, entries: [
|
{ ok: true, warningCode: 'KEYRING_DECRYPT_FAILED', warning: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden', entries: [
|
||||||
{ id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' },
|
{ id: managedOnlineBackupIds.a, 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: 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: [
|
{ ok: true, entries: [
|
||||||
{ id: 'DDDDDDDDDDDDDDDDDDDDDD', 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: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }
|
{ id: managedOnlineBackupIds.a, displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }
|
||||||
] },
|
] },
|
||||||
{ ok: true, entries: [
|
null,
|
||||||
{ id: 'CCCCCCCCCCCCCCCCCCCCCC', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' },
|
{ ok: false, entries: [], code: 'KEYRING_ID_MISMATCH', error: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein' }
|
||||||
{ 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' }
|
|
||||||
];
|
];
|
||||||
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 managedOnlineBackupListIndex = 0;
|
||||||
let managedOnlineBackupCreateIndex = 0;
|
let managedOnlineBackupCreateIndex = 0;
|
||||||
const pendingManagedOnlineBackupLists = new Map();
|
const pendingManagedOnlineBackupLists = new Map();
|
||||||
@@ -98,26 +98,26 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
listManagedOnlineBackups() {
|
listManagedOnlineBackups() {
|
||||||
managedOnlineBackupListIndex++;
|
managedOnlineBackupListIndex++;
|
||||||
managedOnlineBackupProbeCalls.push(['list', managedOnlineBackupListIndex]);
|
managedOnlineBackupProbeCalls.push(['list', managedOnlineBackupListIndex]);
|
||||||
if (managedOnlineBackupListIndex === 5 || managedOnlineBackupListIndex === 7) {
|
if (managedOnlineBackupListIndex === 5) {
|
||||||
return new Promise(resolve => { pendingManagedOnlineBackupLists.set(managedOnlineBackupListIndex, resolve); });
|
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) {
|
releaseManagedOnlineBackupList(index) {
|
||||||
const resolve = pendingManagedOnlineBackupLists.get(index);
|
const resolve = pendingManagedOnlineBackupLists.get(index);
|
||||||
pendingManagedOnlineBackupLists.delete(index);
|
pendingManagedOnlineBackupLists.delete(index);
|
||||||
const entry = index === 5
|
resolve({ ok: true, entries: [
|
||||||
? { id: 'XXXXXXXXXXXXXXXXXXXXXX', displayKey: 'MHU2-XCVB…9753', createdAt: '2026-08-28T12:00:00.000Z' }
|
{ id: managedOnlineBackupIds.d, displayKey: 'MHU2-DFGH…2468', createdAt: '2026-08-25T10:00:00.000Z' },
|
||||||
: { id: 'HHHHHHHHHHHHHHHHHHHHHH', displayKey: 'MHU2-HJKL…8642', createdAt: '2026-08-30T09:00:00.000Z' };
|
{ id: managedOnlineBackupIds.c, displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' },
|
||||||
resolve({ ok: true, entries: [entry] });
|
{ id: managedOnlineBackupIds.a, displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }
|
||||||
|
] });
|
||||||
},
|
},
|
||||||
createManagedOnlineBackup() {
|
createManagedOnlineBackup() {
|
||||||
managedOnlineBackupCreateIndex++;
|
managedOnlineBackupCreateIndex++;
|
||||||
managedOnlineBackupProbeCalls.push(['create', 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
|
const entry = managedOnlineBackupCreateIndex === 1
|
||||||
? { id: 'CCCCCCCCCCCCCCCCCCCCCC', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' }
|
? { id: managedOnlineBackupIds.c, 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.d, displayKey: 'MHU2-DFGH…2468', createdAt: '2026-08-25T10:00:00.000Z' };
|
||||||
return Promise.resolve({ ok: true, entry });
|
return Promise.resolve({ ok: true, entry });
|
||||||
},
|
},
|
||||||
copyManagedOnlineBackup(id) {
|
copyManagedOnlineBackup(id) {
|
||||||
@@ -142,16 +142,24 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
});
|
});
|
||||||
`, 'utf8');
|
`, 'utf8');
|
||||||
const onlineBackupBehaviorScript = `(async () => {
|
const onlineBackupBehaviorScript = `(async () => {
|
||||||
|
const ids = {
|
||||||
|
a: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||||
|
b: 'AQEBAQEBAQEBAQEBAQEBAQ',
|
||||||
|
c: 'AgICAgICAgICAgICAgICAg',
|
||||||
|
d: 'AwMDAwMDAwMDAwMDAwMDAw'
|
||||||
|
};
|
||||||
const fixture = document.createElement('section');
|
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.body.append(fixture);
|
||||||
|
document.documentElement.lang = 'en';
|
||||||
const waitFor = async predicate => {
|
const waitFor = async predicate => {
|
||||||
for (let attempt = 0; attempt < 80; attempt++) {
|
for (let attempt = 0; attempt < 120; attempt++) {
|
||||||
if (await predicate()) return true;
|
if (await predicate()) return true;
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
const calls = async type => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === type);
|
||||||
let confirmation = null;
|
let confirmation = null;
|
||||||
let resolveConfirmation = null;
|
let resolveConfirmation = null;
|
||||||
showAppConfirm = options => {
|
showAppConfirm = options => {
|
||||||
@@ -159,128 +167,97 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
return new Promise(resolve => { resolveConfirmation = resolve; });
|
return new Promise(resolve => { resolveConfirmation = resolve; });
|
||||||
};
|
};
|
||||||
flushPendingSettingsSaves = async () => {};
|
flushPendingSettingsSaves = async () => {};
|
||||||
|
openOnlineBackupView = () => {};
|
||||||
await loadManagedOnlineBackups();
|
await loadManagedOnlineBackups();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
const initialKeys = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent);
|
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' };
|
const initialWarning = {
|
||||||
replaceManagedOnlineBackups([...managedOnlineBackups, maliciousEntry]);
|
hidden: document.getElementById('managedOnlineBackupRefreshStatus')?.hidden,
|
||||||
const secretRejected = !document.body.textContent.includes(maliciousEntry.displayKey) && managedOnlineBackups.every(entry => entry.displayKey !== maliciousEntry.displayKey);
|
text: document.getElementById('managedOnlineBackupRefreshMessage')?.textContent
|
||||||
const originalRow = document.querySelector('.online-backup-managed-row');
|
};
|
||||||
originalRow.querySelector('.online-backup-delete-btn').click();
|
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');
|
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);
|
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));
|
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 = {
|
const refreshFailure = {
|
||||||
rowCount: document.querySelectorAll('.online-backup-managed-row').length,
|
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
|
||||||
stateCount: managedOnlineBackups.length,
|
|
||||||
emptyStateVisible: Boolean(document.querySelector('.online-backup-managed-empty')),
|
|
||||||
status: document.getElementById('onlineBackupStatus').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');
|
const beforeRetryCalls = (await calls('list')).length;
|
||||||
navigation.dataset.settingsPage = 'backup';
|
document.getElementById('reloadManagedOnlineBackupsBtn')?.click();
|
||||||
navigation.addEventListener('click', () => { loadManagedOnlineBackups(); });
|
const retryTriggeredLoad = await waitFor(async () => (await calls('list')).length === beforeRetryCalls + 1);
|
||||||
fixture.append(navigation);
|
if (!retryTriggeredLoad) await loadManagedOnlineBackups();
|
||||||
replaceManagedOnlineBackups([
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
{ id: 'GGGGGGGGGGGGGGGGGGGGGG', displayKey: 'MHU2-GHJK…7531', createdAt: '2026-08-29T12:00:00.000Z' }
|
const afterRetry = {
|
||||||
]);
|
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
|
||||||
setOnlineBackupStatus('', '');
|
warningHidden: document.getElementById('managedOnlineBackupRefreshStatus')?.hidden
|
||||||
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 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);
|
window.api.releaseManagedOnlineBackupList(5);
|
||||||
|
await racingCreate;
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
const staleNavigationResponse = {
|
const raceResult = {
|
||||||
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
|
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
|
||||||
status: document.getElementById('onlineBackupStatus').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();
|
managedOnlineBackups = [];
|
||||||
await waitFor(async () => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length === 7);
|
managedOnlineBackupsAuthoritative = false;
|
||||||
const statusBeforeConcurrentNavigation = {
|
renderManagedOnlineBackups(null);
|
||||||
text: document.getElementById('onlineBackupStatus').textContent,
|
await loadManagedOnlineBackups();
|
||||||
state: document.getElementById('onlineBackupStatus').dataset.state
|
|
||||||
};
|
|
||||||
navigation.click();
|
|
||||||
await waitFor(async () => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length === 8);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
const concurrentNavigationState = {
|
const hardCorruption = {
|
||||||
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
|
emptyStateVisible: Boolean(document.querySelector('.online-backup-managed-empty')),
|
||||||
status: document.getElementById('onlineBackupStatus').textContent,
|
rowCount: document.querySelectorAll('.online-backup-managed-row').length,
|
||||||
statusState: document.getElementById('onlineBackupStatus').dataset.state
|
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 {
|
return {
|
||||||
initialKeys,
|
initialKeys,
|
||||||
secretRejected,
|
initialWarning,
|
||||||
rowReplacedDuringConfirmation,
|
exactSanitizedState,
|
||||||
copyPendingControlsDisabled,
|
ariaDescriptions,
|
||||||
deleteStartedBeforeCopyFinished,
|
|
||||||
deleteStartedAfterCopy,
|
|
||||||
deletePendingControlsDisabled,
|
|
||||||
deleteCallCount: calls.filter(call => call[0] === 'delete').length,
|
|
||||||
afterDelete,
|
afterDelete,
|
||||||
deletePostRefreshCount,
|
|
||||||
afterCreate,
|
|
||||||
authoritativeStateFields,
|
|
||||||
createPostRefreshCount,
|
|
||||||
failedCreatePostRefreshCount,
|
|
||||||
refreshFailure,
|
refreshFailure,
|
||||||
statusBeforeStaleNavigationResponse,
|
retryTriggeredLoad,
|
||||||
staleNavigationResponse,
|
afterRetry,
|
||||||
statusBeforeConcurrentNavigation,
|
copyFocusRestored,
|
||||||
concurrentNavigationState,
|
raceResult,
|
||||||
authoritativeCreateResult,
|
hardCorruption,
|
||||||
completedOperationError,
|
|
||||||
navigationAfterOperationError,
|
|
||||||
confirmation,
|
confirmation,
|
||||||
calls,
|
calls: await window.api.getManagedOnlineBackupProbeCalls(),
|
||||||
secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent)
|
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.ok(result.liveSpeedChart.canvasWidth > 0);
|
||||||
assert.equal(result.liveSpeedChart.baselinePresent, false);
|
assert.equal(result.liveSpeedChart.baselinePresent, false);
|
||||||
assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']);
|
assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']);
|
||||||
assert.equal(result.onlineBackupBehavior.secretRejected, true);
|
assert.deepEqual(result.onlineBackupBehavior.initialWarning, {
|
||||||
assert.equal(result.onlineBackupBehavior.rowReplacedDuringConfirmation, true);
|
hidden: false,
|
||||||
assert.equal(result.onlineBackupBehavior.copyPendingControlsDisabled, true);
|
text: 'Stored online backup key could not be decrypted'
|
||||||
assert.equal(result.onlineBackupBehavior.deleteStartedBeforeCopyFinished, false);
|
});
|
||||||
assert.equal(result.onlineBackupBehavior.deleteStartedAfterCopy, true);
|
assert.equal(result.onlineBackupBehavior.exactSanitizedState, true);
|
||||||
assert.equal(result.onlineBackupBehavior.deletePendingControlsDisabled, true);
|
assert.equal(result.onlineBackupBehavior.ariaDescriptions, true);
|
||||||
assert.equal(result.onlineBackupBehavior.deleteCallCount, 1);
|
assert.deepEqual(result.onlineBackupBehavior.afterDelete, {
|
||||||
assert.deepEqual(result.onlineBackupBehavior.afterDelete, ['MHU2-DFGH…2468', 'MHU2-ABCD…1234']);
|
keys: ['MHU2-ABCD…1234'],
|
||||||
assert.equal(result.onlineBackupBehavior.deletePostRefreshCount, 1);
|
status: 'Key deleted',
|
||||||
assert.deepEqual(result.onlineBackupBehavior.afterCreate, ['MHU2-ETYU…1357', 'MHU2-DFGH…2468', 'MHU2-QWER…4321']);
|
statusState: 'success',
|
||||||
assert.deepEqual(result.onlineBackupBehavior.authoritativeStateFields, ['createdAt', 'displayKey', 'id']);
|
warning: 'Stored online backup key could not be decrypted',
|
||||||
assert.equal(result.onlineBackupBehavior.createPostRefreshCount, 1);
|
retryVisible: true,
|
||||||
assert.equal(result.onlineBackupBehavior.failedCreatePostRefreshCount, 1);
|
focusAction: 'delete',
|
||||||
|
focusId: 'AAAAAAAAAAAAAAAAAAAAAA'
|
||||||
|
});
|
||||||
assert.deepEqual(result.onlineBackupBehavior.refreshFailure, {
|
assert.deepEqual(result.onlineBackupBehavior.refreshFailure, {
|
||||||
rowCount: 0,
|
keys: ['MHU2-QWER…4321', 'MHU2-ABCD…1234'],
|
||||||
stateCount: 0,
|
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,
|
emptyStateVisible: false,
|
||||||
status: 'Online backups could not be loaded',
|
rowCount: 0,
|
||||||
statusState: 'error'
|
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, {
|
assert.deepEqual(result.onlineBackupBehavior.confirmation, {
|
||||||
title: 'Online-Backup löschen',
|
title: 'Online-Backup löschen',
|
||||||
message: 'Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.',
|
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, [
|
assert.deepEqual(result.onlineBackupBehavior.calls, [
|
||||||
['list', 1],
|
['list', 1],
|
||||||
['copy', 'BBBBBBBBBBBBBBBBBBBBBB'],
|
['delete', 'AQEBAQEBAQEBAQEBAQEBAQ'],
|
||||||
['delete', 'BBBBBBBBBBBBBBBBBBBBBB'],
|
|
||||||
['list', 2],
|
['list', 2],
|
||||||
['create', 1],
|
['create', 1],
|
||||||
['list', 3],
|
['list', 3],
|
||||||
['create', 2],
|
|
||||||
['list', 4],
|
['list', 4],
|
||||||
|
['create', 2],
|
||||||
['list', 5],
|
['list', 5],
|
||||||
['create', 3],
|
['copy', 'AAAAAAAAAAAAAAAAAAAAAA'],
|
||||||
['list', 6],
|
['list', 6]
|
||||||
['create', 4],
|
|
||||||
['list', 7],
|
|
||||||
['list', 8],
|
|
||||||
['list', 9],
|
|
||||||
['create', 5],
|
|
||||||
['list', 10]
|
|
||||||
]);
|
]);
|
||||||
assert.equal(result.onlineBackupBehavior.secretInBody, false);
|
assert.equal(result.onlineBackupBehavior.secretInBody, false);
|
||||||
assert.ok(Math.abs(result.onlineBackupLayout.german.createRight - result.onlineBackupLayout.german.contentRight) <= 1);
|
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', () => {
|
test('resolveStartupLanguage accepts only the supported persisted language', () => {
|
||||||
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de');
|
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de');
|
||||||
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en');
|
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en');
|
||||||
|
|||||||
+11
-10
@@ -63,7 +63,7 @@ let initialConfigReadDelayed = false;
|
|||||||
let startupLanguagePendingSnapshot = null;
|
let startupLanguagePendingSnapshot = null;
|
||||||
let managedOnlineBackupEntries = [
|
let managedOnlineBackupEntries = [
|
||||||
{ id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' },
|
{ 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 managedOnlineBackupCopyIds = [];
|
||||||
const managedOnlineBackupDeleteIds = [];
|
const managedOnlineBackupDeleteIds = [];
|
||||||
@@ -74,7 +74,7 @@ const managedOnlineBackupHandlers = {
|
|||||||
'online-backup:list-managed': async () => ({ ok: true, entries: managedOnlineBackupEntries.map(entry => ({ ...entry })) }),
|
'online-backup:list-managed': async () => ({ ok: true, entries: managedOnlineBackupEntries.map(entry => ({ ...entry })) }),
|
||||||
'online-backup:create-managed': async () => {
|
'online-backup:create-managed': async () => {
|
||||||
managedOnlineBackupCreateCalls++;
|
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];
|
managedOnlineBackupEntries = [...managedOnlineBackupEntries, entry];
|
||||||
return { ok: true, entry: { ...entry } };
|
return { ok: true, entry: { ...entry } };
|
||||||
},
|
},
|
||||||
@@ -1405,7 +1405,7 @@ setTimeout(async () => {
|
|||||||
check('Global parallel uploads default 0', parallel === '0');
|
check('Global parallel uploads default 0', parallel === '0');
|
||||||
|
|
||||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'backup\\']")?.click()');
|
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);
|
check('Online backup controls exist', onlineBackupControls);
|
||||||
|
|
||||||
const onlineBackupKeyContract = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput")?.maxLength + "|" + document.getElementById("onlineBackupKeyInput")?.getAttribute("pattern")');
|
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');
|
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 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) }))()');
|
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 newest first without a complete key in the DOM', managedOnlineBackupLoaded === true && managedOnlineBackupInitialState.keys.join('|') === 'MHU2-ZYXW…9876|MHU2-ABCD…1234' && managedOnlineBackupInitialState.secretInBody === false);
|
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 wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-copy-btn").click()');
|
||||||
await waitUntil(() => managedOnlineBackupCopyIds.length === 1);
|
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 wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-delete-btn").click()');
|
||||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
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) }; })()');
|
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();
|
releaseManagedOnlineBackupDelete();
|
||||||
const managedDeleteSucceeded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 1'));
|
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) }))()');
|
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', managedDeletePendingState.count === 2 && managedDeletePendingState.controlsDisabled === true && managedDeleteSucceeded === true && managedDeleteSuccessState.key === 'MHU2-ABCD…1234' && managedDeleteSuccessState.status === 'Schlüssel gelöscht' && managedDeleteSuccessState.secretInBody === false);
|
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()');
|
await wc.executeJavaScript('document.getElementById("createOnlineBackupBtn").click()');
|
||||||
const managedCreateSucceeded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 2'));
|
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) }))()');
|
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 sanitized entry newest first', managedOnlineBackupCreateCalls === 1 && managedCreateSucceeded === true && managedCreateState.first === 'MHU2-QWER…4321' && managedCreateState.secretInBody === false);
|
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');
|
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.');
|
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