diff --git a/lib/online-backup-keyring.js b/lib/online-backup-keyring.js index 31ee51f..3c4f853 100644 --- a/lib/online-backup-keyring.js +++ b/lib/online-backup-keyring.js @@ -5,133 +5,397 @@ const secretStore = require('./secret-store'); const { parseOnlineBackupKey } = require('./online-backup'); const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id']; +const STORED_DOCUMENT_KEYS = ['keys', 'version']; +const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EACCES', 'EBADF', 'EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM']); +const KEYRING_ERROR_CODES = Object.freeze({ + structure: 'KEYRING_STRUCTURE_INVALID', + unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE', + decrypt: 'KEYRING_DECRYPT_FAILED', + mismatch: 'KEYRING_ID_MISMATCH', + duplicate: 'KEYRING_DUPLICATE_ID', + recovered: 'KEYRING_RECOVERED', + encrypt: 'KEYRING_ENCRYPT_FAILED', + plan: 'KEYRING_REMOVE_PLAN_INVALID' +}); +const ERROR_MESSAGES = Object.freeze({ + [KEYRING_ERROR_CODES.structure]: 'Gespeicherter Online-Schlüsselbund ist beschädigt', + [KEYRING_ERROR_CODES.unavailable]: 'Sichere Schlüsselspeicherung ist nicht verfügbar', + [KEYRING_ERROR_CODES.decrypt]: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden', + [KEYRING_ERROR_CODES.mismatch]: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein', + [KEYRING_ERROR_CODES.duplicate]: 'Gespeicherte Online-Sicherungskennung ist mehrdeutig', + [KEYRING_ERROR_CODES.recovered]: 'Online-Schlüsselbund wurde aus einer Wiederherstellungsdatei geladen', + [KEYRING_ERROR_CODES.encrypt]: 'Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden', + [KEYRING_ERROR_CODES.plan]: 'Online-Sicherung konnte lokal nicht eindeutig entfernt werden' +}); +const ISSUE_ORDER = Object.freeze([ + KEYRING_ERROR_CODES.unavailable, + KEYRING_ERROR_CODES.duplicate, + KEYRING_ERROR_CODES.decrypt, + KEYRING_ERROR_CODES.mismatch, + KEYRING_ERROR_CODES.structure, + KEYRING_ERROR_CODES.recovered +]); + +class OnlineBackupKeyringError extends Error { + constructor(code) { + super(ERROR_MESSAGES[code] || ERROR_MESSAGES[KEYRING_ERROR_CODES.structure]); + this.name = 'OnlineBackupKeyringError'; + this.code = code; + } +} function isObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +function hasExactKeys(value, expected) { + if (!isObject(value)) return false; + const keys = Object.keys(value).sort(); + return keys.length === expected.length && keys.every((key, index) => key === expected[index]); +} + +function isCanonicalId(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/u.test(value)) return false; + const decoded = Buffer.from(value, 'base64url'); + return decoded.length === 16 && decoded.toString('base64url') === value; +} + function normalizeTimestamp(value) { const timestamp = new Date(value); - if (!Number.isFinite(timestamp.getTime())) throw new Error('Ungültiger Erstellungszeitpunkt'); + if (!Number.isFinite(timestamp.getTime())) throw new OnlineBackupKeyringError(KEYRING_ERROR_CODES.structure); return timestamp.toISOString(); } +function uniqueIssues(issues) { + const values = [...new Set(issues)]; + values.sort((left, right) => ISSUE_ORDER.indexOf(left) - ISSUE_ORDER.indexOf(right)); + return values; +} + function createOnlineBackupKeyring({ filePath, encryptField = secretStore.encryptField, decryptField = secretStore.decryptField, + isEncrypted = secretStore.isEncrypted, parseKey = parseOnlineBackupKey, fsImpl = fs.promises }) { + const directory = path.dirname(filePath); + const basename = path.basename(filePath); + const backupPath = `${filePath}.bak`; + const temporaryPrefix = `.${basename}.`; + const removalPlans = new WeakMap(); let mutation = Promise.resolve(); - function validateEntry(entry) { - if (!isObject(entry)) return null; - const keys = Object.keys(entry).sort(); - if ( - keys.length !== STORED_ENTRY_KEYS.length - || !keys.every((key, index) => key === STORED_ENTRY_KEYS[index]) - || typeof entry.id !== 'string' - || !entry.id - || typeof entry.encryptedKey !== 'string' - || !entry.encryptedKey - || typeof entry.createdAt !== 'string' - ) { - return null; - } - let key; - let parsed; - try { - key = decryptField(entry.encryptedKey); - parsed = parseKey(key); - } catch { - return null; - } - if (typeof key !== 'string' || parsed?.id !== entry.id) return null; - let createdAt; - try { - createdAt = normalizeTimestamp(entry.createdAt); - } catch { - return null; - } - if (createdAt !== entry.createdAt) return null; - return { id: entry.id, encryptedKey: entry.encryptedKey, createdAt, key }; + function issueError(code) { + return new OnlineBackupKeyringError(code); } - async function readEntries(rejectInvalidEntries = false) { - let contents; - try { - contents = await fsImpl.readFile(filePath, 'utf8'); - } catch (error) { - if (error?.code === 'ENOENT') return []; - throw error; - } + function isTemporaryFileName(value) { + return value.startsWith(temporaryPrefix) + && /^\d+\.[0-9a-f-]+\.(?:primary|recovery)\.tmp$/u.test(value.slice(temporaryPrefix.length)); + } + + function encryptionError(error) { + return issueError(error?.code === 'SECRET_STORE_UNAVAILABLE' ? KEYRING_ERROR_CODES.unavailable : KEYRING_ERROR_CODES.encrypt); + } + + function decryptionIssue(error) { + return error?.code === 'SECRET_STORE_UNAVAILABLE' ? KEYRING_ERROR_CODES.unavailable : KEYRING_ERROR_CODES.decrypt; + } + + function parseDocument(contents) { let document; try { document = JSON.parse(contents); } catch { - throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig'); + throw issueError(KEYRING_ERROR_CODES.structure); } - if (!isObject(document) || document.version !== 1 || !Array.isArray(document.entries)) { - throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig'); + if (!hasExactKeys(document, STORED_DOCUMENT_KEYS) || document.version !== 1 || !Array.isArray(document.keys)) { + throw issueError(KEYRING_ERROR_CODES.structure); } - const entries = document.entries.map(validateEntry); - if (rejectInvalidEntries && entries.some((entry) => !entry)) { - throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig'); - } - return entries.filter(Boolean); + return document; } - async function writeEntries(entries) { - const directory = path.dirname(filePath); - const temporaryPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`); - const document = JSON.stringify({ - version: 1, - entries: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt })) - }); - await fsImpl.mkdir(directory, { recursive: true }); + async function readCandidate(candidatePath) { try { - await fsImpl.writeFile(temporaryPath, document, { encoding: 'utf8', flag: 'wx' }); - await fsImpl.rename(temporaryPath, filePath); + const contents = await fsImpl.readFile(candidatePath, 'utf8'); + return { status: 'valid', path: candidatePath, contents, document: parseDocument(contents) }; } catch (error) { - try { - await fsImpl.unlink(temporaryPath); - } catch (cleanupError) { - if (cleanupError?.code !== 'ENOENT') throw cleanupError; - } - throw error; + if (error?.code === 'ENOENT') return { status: 'missing', path: candidatePath }; + return { status: 'invalid', path: candidatePath }; } } + async function recoveryCandidates() { + const candidates = new Set([backupPath]); + try { + const entries = await fsImpl.readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && isTemporaryFileName(entry.name)) candidates.add(path.join(directory, entry.name)); + } + } 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) { const next = mutation.catch(() => {}).then(operation); mutation = 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() { - const entries = await readEntries(); - const sanitized = entries + const state = await readState(); + const entries = state.entries .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) .map(({ id, key, createdAt }) => Object.freeze({ id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, createdAt })); - return Object.freeze(sanitized); + return Object.freeze({ + entries: Object.freeze(entries), + issues: Object.freeze([...state.issues]) + }); } function prepare(key, createdAt) { let parsed; - let encryptedKey; try { parsed = parseKey(key); - encryptedKey = encryptField(key); } catch { - throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden'); + throw issueError(KEYRING_ERROR_CODES.structure); } - if (typeof encryptedKey !== 'string' || !encryptedKey || encryptedKey === key) { - throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden'); + let encryptedKey; + try { + encryptedKey = encryptField(key); + } catch (error) { + throw encryptionError(error); + } + if (typeof encryptedKey !== 'string' || encryptedKey === key || !isEncrypted(encryptedKey)) { + throw issueError(KEYRING_ERROR_CODES.encrypt); } return Object.freeze({ id: parsed.id, @@ -143,29 +407,60 @@ function createOnlineBackupKeyring({ function commit(entry) { return serialize(async () => { const validated = validateEntry(entry); - if (!validated) throw new Error('Online-Sicherungsschlüssel ist ungültig'); - const entries = await readEntries(true); - if (entries.some((current) => current.id === validated.id)) return; - await writeEntries([...entries, validated]); - }); - } - - function remove(id) { - return serialize(async () => { - const entries = await readEntries(true); - const remaining = entries.filter((entry) => entry.id !== id); - if (remaining.length === entries.length) return false; - await writeEntries(remaining); + if (!validated.entry) throw issueError(validated.issue); + const state = await readState(); + const blockingIssue = firstBlockingIssue(state); + if (blockingIssue) throw issueError(blockingIssue); + if (state.entries.some(current => current.id === validated.entry.id)) return false; + await writeEntries([...state.entries, validated.entry]); return true; }); } async function getKey(id) { - const entries = await readEntries(); - return entries.find((entry) => entry.id === id)?.key ?? null; + const state = await readState(); + if (state.duplicateIds.has(id)) throw issueError(KEYRING_ERROR_CODES.duplicate); + const entry = state.entries.find(current => current.id === id); + if (entry) return entry.key; + const matchingProblem = state.problems.find(problem => problem.id === id); + if (matchingProblem) throw issueError(matchingProblem.code); + const blockingIssue = firstBlockingIssue(state); + if (blockingIssue) throw issueError(blockingIssue); + return null; } - return Object.freeze({ list, prepare, commit, remove, getKey }); + async function prepareRemove(id) { + const state = await readState(); + const blockingIssue = firstBlockingIssue(state); + if (blockingIssue) throw issueError(blockingIssue); + const entry = state.entries.find(current => current.id === id); + if (!entry) return null; + const plan = Object.freeze({ id: entry.id, key: entry.key }); + removalPlans.set(plan, state.entries.filter(current => current.id !== id)); + return plan; + } + + function commitRemove(plan) { + return serialize(async () => { + const remaining = removalPlans.get(plan); + if (!remaining) throw issueError(KEYRING_ERROR_CODES.plan); + await writeEntries(remaining); + removalPlans.delete(plan); + return true; + }); + } + + async function remove(id) { + const plan = await prepareRemove(id); + if (!plan) return false; + return commitRemove(plan); + } + + return Object.freeze({ list, prepare, commit, remove, getKey, prepareRemove, commitRemove }); } -module.exports = { createOnlineBackupKeyring }; +module.exports = { + KEYRING_ERROR_CODES, + OnlineBackupKeyringError, + createOnlineBackupKeyring +}; diff --git a/lib/online-backup-manager.js b/lib/online-backup-manager.js index a56ff45..fe555c3 100644 --- a/lib/online-backup-manager.js +++ b/lib/online-backup-manager.js @@ -3,6 +3,7 @@ const { deleteOnlineBackup, uploadOnlineBackup } = require('./online-backup'); +const { KEYRING_ERROR_CODES } = require('./online-backup-keyring'); const ERRORS = Object.freeze({ list: 'Online-Sicherungen konnten nicht geladen werden', @@ -11,6 +12,16 @@ const ERRORS = Object.freeze({ delete: 'Online-Sicherung konnte nicht gelöscht werden', notFound: 'Online-Sicherungsschlüssel wurde nicht gefunden' }); +const KEYRING_MESSAGES = Object.freeze({ + [KEYRING_ERROR_CODES.structure]: 'Gespeicherter Online-Schlüsselbund ist beschädigt', + [KEYRING_ERROR_CODES.unavailable]: 'Sichere Schlüsselspeicherung ist nicht verfügbar', + [KEYRING_ERROR_CODES.decrypt]: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden', + [KEYRING_ERROR_CODES.mismatch]: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein', + [KEYRING_ERROR_CODES.duplicate]: 'Gespeicherte Online-Sicherungskennung ist mehrdeutig', + [KEYRING_ERROR_CODES.recovered]: 'Online-Schlüsselbund wurde aus einer Wiederherstellungsdatei geladen', + [KEYRING_ERROR_CODES.encrypt]: 'Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden', + [KEYRING_ERROR_CODES.plan]: 'Online-Sicherung konnte lokal nicht eindeutig entfernt werden' +}); function sanitizeEntry(entry) { return { @@ -28,6 +39,12 @@ function sanitizeCreatedEntry(entry, key) { }; } +function keyringFailure(error, fallback) { + const message = KEYRING_MESSAGES[error?.code]; + if (!message) return { ok: false, error: fallback }; + return { ok: false, code: error.code, error: message }; +} + function createOnlineBackupManager({ keyring, loadSettings, @@ -46,8 +63,12 @@ function createOnlineBackupManager({ } async function listEntries() { - const entries = await keyring.list(); - return entries.map(sanitizeEntry); + const snapshot = await keyring.list(); + if (!snapshot || !Array.isArray(snapshot.entries) || !Array.isArray(snapshot.issues)) throw new Error(ERRORS.list); + return { + entries: snapshot.entries.map(sanitizeEntry), + issues: snapshot.issues + }; } async function createTransaction() { @@ -68,27 +89,39 @@ function createOnlineBackupManager({ } async function deleteTransaction(id) { - const key = await keyring.getKey(id); - if (!key) return { ok: false, notFound: true, error: ERRORS.notFound }; - const outcome = await deleteBackup(key); + const plan = await keyring.prepareRemove(id); + if (!plan) return { ok: false, notFound: true, error: ERRORS.notFound }; + const outcome = await deleteBackup(plan.key); if (!outcome?.deleted && !outcome?.notFound) throw new Error(ERRORS.delete); - await keyring.remove(id); + await keyring.commitRemove(plan); return { ok: true, removedId: id, notFound: outcome.notFound }; } async function listManaged() { try { - return { ok: true, entries: await listEntries() }; - } catch { - return { ok: false, error: ERRORS.list }; + const snapshot = await listEntries(); + const issue = snapshot.issues[0]; + if (!issue) return { ok: true, entries: snapshot.entries }; + const message = KEYRING_MESSAGES[issue] || ERRORS.list; + if (snapshot.entries.length || issue === KEYRING_ERROR_CODES.recovered) { + return { + ok: true, + entries: snapshot.entries, + warningCode: issue, + warning: message + }; + } + return { ok: false, entries: [], code: issue, error: message }; + } catch (error) { + return keyringFailure(error, ERRORS.list); } } async function createManaged() { try { return await serialize(createTransaction); - } catch { - return { ok: false, error: ERRORS.create }; + } catch (error) { + return keyringFailure(error, ERRORS.create); } } @@ -98,16 +131,16 @@ function createOnlineBackupManager({ if (!key) return { ok: false, notFound: true, error: ERRORS.notFound }; await copyText(key); return { ok: true }; - } catch { - return { ok: false, error: ERRORS.copy }; + } catch (error) { + return keyringFailure(error, ERRORS.copy); } } async function deleteManaged(id) { try { return await serialize(() => deleteTransaction(id)); - } catch { - return { ok: false, error: ERRORS.delete }; + } catch (error) { + return keyringFailure(error, ERRORS.delete); } } diff --git a/lib/secret-store.js b/lib/secret-store.js index 6179cad..c11c124 100644 --- a/lib/secret-store.js +++ b/lib/secret-store.js @@ -35,7 +35,11 @@ function getSafeStorage() { } function isEncrypted(value) { - return typeof value === 'string' && value.startsWith(SENTINEL); + if (typeof value !== 'string' || !value.startsWith(SENTINEL)) return false; + const encoded = value.slice(SENTINEL.length); + if (!encoded || encoded.length % 4 !== 0) return false; + const decoded = Buffer.from(encoded, 'base64'); + return decoded.length > 0 && decoded.toString('base64') === encoded; } function encryptField(value) { @@ -55,7 +59,12 @@ function encryptField(value) { function decryptField(value) { if (!value || typeof value !== 'string') return value; - if (!isEncrypted(value)) return value; + if (!isEncrypted(value)) { + if (value.startsWith(SENTINEL)) { + throw new SecretStoreError('SECRET_STORE_DECRYPT_FAILED', 'Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden'); + } + return value; + } const ss = getSafeStorage(); if (!ss) { throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar'); diff --git a/renderer/app.js b/renderer/app.js index 67e9c52..1703840 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -42,6 +42,7 @@ function refreshLocalizedRuntimeUi() { const hint = document.getElementById('recentFilesHint'); if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links'); renderManagedOnlineBackups(); + renderManagedOnlineBackupRefreshIssue(); } // Dropdown options for "Add Account" modal: value -> label @@ -64,11 +65,12 @@ let uploading = false; let healthCheckRunning = false; let managedOnlineBackups = []; let managedOnlineBackupsAuthoritative = false; -let managedOnlineBackupOperationGeneration = 0; +let managedOnlineBackupMutationGeneration = 0; let managedOnlineBackupNavigationLoadGeneration = 0; let managedOnlineBackupAuthoritativeLoadGeneration = 0; -let managedOnlineBackupActiveOperations = 0; +let managedOnlineBackupActiveMutations = 0; let onlineBackupStatusContextGeneration = 0; +let managedOnlineBackupRefreshIssue = null; const managedOnlineBackupOperationQueues = new Map(); let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0; @@ -2647,15 +2649,18 @@ function beginOnlineBackupStatusContext() { return ++onlineBackupStatusContextGeneration; } -function beginManagedOnlineBackupOperation() { - managedOnlineBackupActiveOperations++; - managedOnlineBackupOperationGeneration++; +function beginManagedOnlineBackupMutation() { + managedOnlineBackupActiveMutations++; + managedOnlineBackupMutationGeneration++; managedOnlineBackupNavigationLoadGeneration++; - return beginOnlineBackupStatusContext(); + return Object.freeze({ + mutationGeneration: managedOnlineBackupMutationGeneration, + statusContext: beginOnlineBackupStatusContext() + }); } -function endManagedOnlineBackupOperation() { - managedOnlineBackupActiveOperations = Math.max(0, managedOnlineBackupActiveOperations - 1); +function endManagedOnlineBackupMutation() { + managedOnlineBackupActiveMutations = Math.max(0, managedOnlineBackupActiveMutations - 1); } function setOnlineBackupStatus(message, state = '', statusContext = null) { @@ -2675,34 +2680,84 @@ function syncOnlineBackupRestoreButton(busy = false) { restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || ''); } -function normalizeManagedOnlineBackups(entries) { - const sanitized = new Map(); - for (const entry of Array.isArray(entries) ? entries : []) { - if (!entry || typeof entry !== 'object') continue; - if (typeof entry.id !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(entry.id)) continue; - if (typeof entry.displayKey !== 'string' || !entry.displayKey || /MHU2-[A-Za-z0-9_-]{70}/.test(entry.displayKey)) continue; - const createdAt = new Date(entry.createdAt); - if (Number.isNaN(createdAt.getTime())) continue; - sanitized.set(entry.id, { id: entry.id, displayKey: entry.displayKey, createdAt: createdAt.toISOString() }); +function isCanonicalManagedOnlineBackupId(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(value)) return false; + try { + const decoded = window.atob(`${value.replace(/-/g, '+').replace(/_/g, '/')}==`); + if (decoded.length !== 16) return false; + const encoded = window.btoa(decoded).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + return encoded === value; + } catch { + return false; } - return [...sanitized.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt)); } -function replaceManagedOnlineBackups(entries) { +function normalizeManagedOnlineBackups(entries) { + const candidates = []; + for (const entry of Array.isArray(entries) ? entries : []) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + if (Object.keys(entry).sort().join(',') !== 'createdAt,displayKey,id') continue; + if (!isCanonicalManagedOnlineBackupId(entry.id)) continue; + if (typeof entry.displayKey !== 'string' || !/^MHU2-[A-Za-z0-9_-]{4}…[A-Za-z0-9_-]{4}$/.test(entry.displayKey)) continue; + const createdAt = new Date(entry.createdAt); + if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== entry.createdAt) continue; + candidates.push({ id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt }); + } + const counts = new Map(); + for (const entry of candidates) counts.set(entry.id, (counts.get(entry.id) || 0) + 1); + return candidates + .filter(entry => counts.get(entry.id) === 1) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); +} + +function managedOnlineBackupFocusTarget() { + const active = document.activeElement; + const id = active?.dataset?.managedOnlineBackupId; + const action = active?.dataset?.managedOnlineBackupAction; + if (!id || !action) return null; + return { id, action, fallbackIndex: managedOnlineBackups.findIndex(entry => entry.id === id) }; +} + +function restoreManagedOnlineBackupFocus(target) { + if (!target) return; + let button = [...document.querySelectorAll('[data-managed-online-backup-action]')] + .find(candidate => candidate.dataset.managedOnlineBackupId === target.id && candidate.dataset.managedOnlineBackupAction === target.action); + if (!button && Number.isInteger(target.fallbackIndex)) { + const rows = [...document.querySelectorAll('.online-backup-managed-row')]; + const row = rows[Math.min(Math.max(0, target.fallbackIndex), Math.max(0, rows.length - 1))]; + button = row?.querySelector(`[data-managed-online-backup-action="${target.action}"]`); + } + button ||= document.getElementById('createOnlineBackupBtn'); + if (button && !button.disabled) button.focus(); +} + +function replaceManagedOnlineBackups(entries, focusTarget = undefined) { + const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget; managedOnlineBackups = normalizeManagedOnlineBackups(entries); managedOnlineBackupsAuthoritative = true; - renderManagedOnlineBackups(); + renderManagedOnlineBackups(target); } -function invalidateManagedOnlineBackups() { - managedOnlineBackups = []; - managedOnlineBackupsAuthoritative = false; - renderManagedOnlineBackups(); +function upsertManagedOnlineBackup(entry) { + const normalized = normalizeManagedOnlineBackups([entry]); + if (normalized.length !== 1) return false; + replaceManagedOnlineBackups([ + ...managedOnlineBackups.filter(current => current.id !== normalized[0].id), + normalized[0] + ]); + return true; } -function renderManagedOnlineBackups() { +function removeManagedOnlineBackup(id, focusTarget = null) { + if (!hasManagedOnlineBackup(id)) return false; + replaceManagedOnlineBackups(managedOnlineBackups.filter(entry => entry.id !== id), focusTarget); + return true; +} + +function renderManagedOnlineBackups(focusTarget = undefined) { const list = document.getElementById('managedOnlineBackupList'); if (!list) return; + const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget; const content = document.createDocumentFragment(); if (!managedOnlineBackupsAuthoritative) { list.replaceChildren(content); @@ -2717,8 +2772,10 @@ function renderManagedOnlineBackups() { for (const entry of managedOnlineBackups) { const row = document.createElement('article'); row.className = 'online-backup-managed-row'; + row.dataset.managedOnlineBackupId = entry.id; const key = document.createElement('span'); key.className = 'online-backup-managed-key'; + key.id = `managedOnlineBackupKey-${entry.id}`; key.textContent = entry.displayKey; const created = document.createElement('span'); created.className = 'online-backup-managed-created'; @@ -2729,10 +2786,16 @@ function renderManagedOnlineBackups() { copyButton.type = 'button'; copyButton.className = 'btn btn-secondary online-backup-copy-btn'; copyButton.textContent = localizeUiText('Schlüssel kopieren'); + copyButton.dataset.managedOnlineBackupId = entry.id; + copyButton.dataset.managedOnlineBackupAction = 'copy'; + copyButton.setAttribute('aria-describedby', key.id); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'btn btn-danger online-backup-delete-btn'; deleteButton.textContent = localizeUiText('Online-Backup löschen'); + deleteButton.dataset.managedOnlineBackupId = entry.id; + deleteButton.dataset.managedOnlineBackupAction = 'delete'; + deleteButton.setAttribute('aria-describedby', key.id); const pending = managedOnlineBackupOperationQueues.has(entry.id); copyButton.disabled = pending; deleteButton.disabled = pending; @@ -2744,39 +2807,61 @@ function renderManagedOnlineBackups() { } } list.replaceChildren(content); + restoreManagedOnlineBackupFocus(target); } -async function loadManagedOnlineBackups({ statusContext = null } = {}) { - const authoritative = statusContext !== null; - const operationGeneration = managedOnlineBackupOperationGeneration; - const startedDuringOperation = managedOnlineBackupActiveOperations > 0; +function renderManagedOnlineBackupRefreshIssue() { + const container = document.getElementById('managedOnlineBackupRefreshStatus'); + const message = document.getElementById('managedOnlineBackupRefreshMessage'); + const retry = document.getElementById('reloadManagedOnlineBackupsBtn'); + if (!container || !message || !retry) return; + const issue = managedOnlineBackupRefreshIssue; + container.hidden = !issue; + container.dataset.state = issue?.state || ''; + message.textContent = issue ? localizeUiText(issue.message) : ''; + retry.textContent = localizeUiText('Erneut laden'); + retry.onclick = issue ? async () => { + retry.disabled = true; + await loadManagedOnlineBackups(); + if (!retry.isConnected) return; + retry.disabled = false; + if (!container.hidden) retry.focus(); + else restoreManagedOnlineBackupFocus({ id: managedOnlineBackups[0]?.id, action: 'copy', fallbackIndex: 0 }); + } : null; +} + +function setManagedOnlineBackupRefreshIssue(message = '', state = 'warning') { + const sanitized = String(message || '').replace(/MHU2-[A-Za-z0-9_-]{70}/gu, localizeUiText('Geschützter Schlüssel')); + managedOnlineBackupRefreshIssue = sanitized ? { message: sanitized, state } : null; + renderManagedOnlineBackupRefreshIssue(); +} + +async function loadManagedOnlineBackups({ mutationGeneration = null } = {}) { + const authoritative = mutationGeneration !== null; + const capturedMutationGeneration = managedOnlineBackupMutationGeneration; + const startedDuringMutation = managedOnlineBackupActiveMutations > 0; const generation = authoritative ? ++managedOnlineBackupAuthoritativeLoadGeneration : ++managedOnlineBackupNavigationLoadGeneration; - const loaderStatusContext = authoritative || !startedDuringOperation ? statusContext ?? beginOnlineBackupStatusContext() : null; - const mayUpdateStatus = loaderStatusContext !== null; try { const result = await window.api.listManagedOnlineBackups(); const stale = authoritative - ? generation !== managedOnlineBackupAuthoritativeLoadGeneration || operationGeneration !== managedOnlineBackupOperationGeneration - : generation !== managedOnlineBackupNavigationLoadGeneration || operationGeneration !== managedOnlineBackupOperationGeneration || startedDuringOperation; + ? generation !== managedOnlineBackupAuthoritativeLoadGeneration || mutationGeneration !== managedOnlineBackupMutationGeneration + : generation !== managedOnlineBackupNavigationLoadGeneration || capturedMutationGeneration !== managedOnlineBackupMutationGeneration || startedDuringMutation; if (stale) return false; if (!result?.ok || !Array.isArray(result.entries)) { - invalidateManagedOnlineBackups(); - if (mayUpdateStatus) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error', loaderStatusContext); + setManagedOnlineBackupRefreshIssue(result?.error || 'Online-Sicherungen konnten nicht geladen werden', 'error'); return false; } replaceManagedOnlineBackups(result.entries); - if (mayUpdateStatus) setOnlineBackupStatus('', '', loaderStatusContext); + if (result.warning) setManagedOnlineBackupRefreshIssue(result.warning, 'warning'); + else setManagedOnlineBackupRefreshIssue(); return true; } catch { const current = authoritative - ? generation === managedOnlineBackupAuthoritativeLoadGeneration && operationGeneration === managedOnlineBackupOperationGeneration - : generation === managedOnlineBackupNavigationLoadGeneration && operationGeneration === managedOnlineBackupOperationGeneration && !startedDuringOperation; - if (current) { - invalidateManagedOnlineBackups(); - if (mayUpdateStatus) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error', loaderStatusContext); - } + ? generation === managedOnlineBackupAuthoritativeLoadGeneration && mutationGeneration === managedOnlineBackupMutationGeneration + : generation === managedOnlineBackupNavigationLoadGeneration && capturedMutationGeneration === managedOnlineBackupMutationGeneration && !startedDuringMutation; + if (current) setManagedOnlineBackupRefreshIssue('Online-Sicherungen konnten nicht geladen werden', 'error'); return false; } } @@ -2785,15 +2870,21 @@ function hasManagedOnlineBackup(id) { return managedOnlineBackups.some(entry => entry.id === id); } -function enqueueManagedOnlineBackupOperation(id, action, operation) { - const statusContext = beginManagedOnlineBackupOperation(); +function enqueueManagedOnlineBackupOperation(id, action, operation, mutation = false) { const previous = managedOnlineBackupOperationQueues.get(id)?.promise || Promise.resolve(); - const promise = previous.catch(() => {}).then(() => operation(statusContext)); - const state = { action, promise }; + const state = { action, promise: null }; + const promise = previous.catch(() => {}).then(async () => { + const authority = mutation ? beginManagedOnlineBackupMutation() : null; + try { + return await operation(authority); + } finally { + if (authority) endManagedOnlineBackupMutation(); + } + }); + state.promise = promise; managedOnlineBackupOperationQueues.set(id, state); renderManagedOnlineBackups(); return promise.finally(() => { - endManagedOnlineBackupOperation(); if (managedOnlineBackupOperationQueues.get(id) !== state) return; managedOnlineBackupOperationQueues.delete(id); renderManagedOnlineBackups(); @@ -2803,18 +2894,20 @@ function enqueueManagedOnlineBackupOperation(id, action, operation) { async function copyManagedOnlineBackup(entry) { const id = entry.id; if (!hasManagedOnlineBackup(id) || managedOnlineBackupOperationQueues.has(id)) return; - await enqueueManagedOnlineBackupOperation(id, 'copy', async (statusContext) => { + const focusTarget = { id, action: 'copy', fallbackIndex: managedOnlineBackups.findIndex(current => current.id === id) }; + await enqueueManagedOnlineBackupOperation(id, 'copy', async () => { try { const result = await window.api.copyManagedOnlineBackup(id); if (!result?.ok) { - setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error', statusContext); + showCopyToast(result?.error || 'Online-Sicherung konnte nicht kopiert werden'); return; } showCopyToast('Online-Schlüssel kopiert'); } catch { - setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error', statusContext); + showCopyToast('Online-Sicherung konnte nicht kopiert werden'); } }); + renderManagedOnlineBackups(focusTarget); } async function deleteManagedOnlineBackup(entry) { @@ -2827,32 +2920,35 @@ async function deleteManagedOnlineBackup(entry) { }); if (!confirmed) return; if (!hasManagedOnlineBackup(id)) { - const statusContext = beginManagedOnlineBackupOperation(); + const statusContext = beginOnlineBackupStatusContext(); setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext); - endManagedOnlineBackupOperation(); return; } if (managedOnlineBackupOperationQueues.get(id)?.action === 'delete') { setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error'); return; } - await enqueueManagedOnlineBackupOperation(id, 'delete', async (statusContext) => { + const fallbackIndex = managedOnlineBackups.findIndex(current => current.id === id); + let removed = false; + await enqueueManagedOnlineBackupOperation(id, 'delete', async (authority) => { if (!hasManagedOnlineBackup(id)) { - setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext); + setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', authority.statusContext); return; } try { const result = await window.api.deleteManagedOnlineBackup(id); if (!result?.ok || result.removedId !== id) { - setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext); + setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht gelöscht werden', 'error', authority.statusContext); return; } - if (!await loadManagedOnlineBackups({ statusContext })) return; - setOnlineBackupStatus('Schlüssel gelöscht', 'success', statusContext); + removed = removeManagedOnlineBackup(id, { id, action: 'delete', fallbackIndex }); + await loadManagedOnlineBackups({ mutationGeneration: authority.mutationGeneration }); + setOnlineBackupStatus('Schlüssel gelöscht', 'success', authority.statusContext); } catch { - setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext); + setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', authority.statusContext); } - }); + }, true); + renderManagedOnlineBackups({ id, action: 'delete', fallbackIndex: removed ? fallbackIndex : managedOnlineBackups.findIndex(current => current.id === id) }); } async function doOnlineBackupCreate() { @@ -2863,29 +2959,29 @@ async function doOnlineBackupCreate() { openOnlineBackupView(); } catch { openOnlineBackupView(); - const statusContext = beginManagedOnlineBackupOperation(); + const statusContext = beginOnlineBackupStatusContext(); setOnlineBackupStatus('Nicht alle Einstellungen konnten gespeichert werden', 'error', statusContext); - endManagedOnlineBackupOperation(); doOnlineBackupCreate.busy = false; return; } - const statusContext = beginManagedOnlineBackupOperation(); + const authority = beginManagedOnlineBackupMutation(); const createButton = document.getElementById('createOnlineBackupBtn'); if (createButton) createButton.disabled = true; - setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', statusContext); + setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', authority.statusContext); try { const result = await window.api.createManagedOnlineBackup(); if (!result?.ok) { - setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', statusContext); + setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext); return; } - if (!await loadManagedOnlineBackups({ statusContext })) return; - setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success', statusContext); + if (!upsertManagedOnlineBackup(result.entry)) setManagedOnlineBackupRefreshIssue('Online-Sicherungen konnten nicht geladen werden', 'error'); + await loadManagedOnlineBackups({ mutationGeneration: authority.mutationGeneration }); + setOnlineBackupStatus('Neuer Schlüssel erstellt.', 'success', authority.statusContext); showCopyToast('Online-Schlüssel erstellt'); } catch { - setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', statusContext); + setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext); } finally { - endManagedOnlineBackupOperation(); + endManagedOnlineBackupMutation(); if (createButton?.isConnected) createButton.disabled = false; doOnlineBackupCreate.busy = false; } @@ -5088,6 +5184,10 @@ function renderSettings() {

Auf diesem Gerät erstellt

+