From 7f6adac78bd383c8a665bc3ce9172d9e134d8a26 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 22 Sep 2026 04:34:27 +0200 Subject: [PATCH] Add encrypted administrative recovery for online backup keys --- .gitignore | 3 + PROJECT_MEMORY.md | 6 ++ services/backup-api/README.md | 39 +++++++++- services/backup-api/src/cli.mjs | 6 +- services/backup-api/src/recovery-admin.mjs | 63 +++++++++++++++ services/backup-api/src/recovery.d.mts | 2 + services/backup-api/src/recovery.mjs | 46 +++++++++++ services/backup-api/src/server.d.mts | 1 + services/backup-api/src/server.mjs | 16 +++- src/main/app-controller.ts | 2 +- src/main/online-backup.ts | 30 +++++++- src/renderer/App.tsx | 4 +- src/renderer/i18n.ts | 4 +- tests/i18n.test.ts | 3 +- tests/online-backup-service.test.ts | 89 ++++++++++++++++++++++ tests/online-backup.test.ts | 18 +++++ 16 files changed, 318 insertions(+), 14 deletions(-) create mode 100644 services/backup-api/src/recovery-admin.mjs create mode 100644 services/backup-api/src/recovery.d.mts create mode 100644 services/backup-api/src/recovery.mjs diff --git a/.gitignore b/.gitignore index 65919ad..211e9dd 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ rd_download_manifest.json _update_staging/ apply_update.cmd .env +*.pem +services/backup-api/data/ +services/backup-api/recovery-keys/ .env.* !.env.example .DS_Store diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index b2c72a4..ef40255 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -8,6 +8,12 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält ## Zuletzt verifizierter Stand +- 22. September 2026: Administrative Wiederherstellung für neue Online-Sicherungsschlüssel implementiert, noch nicht veröffentlicht oder produktiv eingerichtet. App-Exporte verlangen nun einen konfigurierten öffentlichen RSA-3072-Schlüssel vom Backup-Dienst und hinterlegen den MDD2-Schlüssel ausschließlich als OAEP-SHA-256-verschlüsselte Kopie. OAEP bindet ID, Blob-Hash und Löschverifikator. Speicherung/Löschung der Kopie erfolgt atomar mit dem bisherigen Datensatz; alte Clients und vorhandene Sicherungen bleiben kompatibel. Ohne Recovery-Konfiguration scheitern neue Exporte ausdrücklich statt still ohne Schlüsselkopie zu speichern. +- Sicherheitsentscheidung ausdrücklich von Sascha bestätigt: Der Betreiber darf mit dem separat geschützten privaten Wiederherstellungsschlüssel Online-Schlüssel und damit Sicherungsinhalte einschließlich Zugangsdaten entschlüsseln. Der öffentliche Dienst erhält nur `RECOVERY_PUBLIC_KEY_FILE`, niemals den privaten Schlüssel; keine öffentliche Recovery-/Listen-API. Der Exportdialog informiert darüber auf Deutsch/Englisch. Bereits verlorene Schlüssel alter Sicherungen lassen sich nicht nachträglich rekonstruieren. +- Administratives Werkzeug: `node services/backup-api/src/recovery-admin.mjs init `, `list ` und `recover `. Nur Datum, ID und Schlüsselfingerabdruck werden aufgelistet. Recovery prüft OAEP, MDD2-Prüfsumme, Löschverifikator und AES-GCM; Ausgabe ausschließlich in eine neue Datei außerhalb des Datenverzeichnisses, keine Klartextschlüssel im Terminal. Keine bestehenden Dateien werden überschrieben. Unix-Rechte 0600/0700; auf Windows zusätzlich NTFS-ACLs erforderlich. Private Schlüssel getrennt verschlüsselt sichern und bei Rotation ältere Schlüssel behalten. +- Verifiziert: TypeScript, Main-/Renderer-Build, Self-Check, alle 16 Backup-API-Tests und gezielte Backup-/Recovery-Tests. Zusätzlicher Lauf für Recovery, Übersetzungen und Release-Metadaten: 266 Tests bestanden, die zwei bekannten Windows-Symlink-Fixtures gezielt ausgenommen. Neue Tests prüfen erfolgreichen HTTP-/Admin-Roundtrip, falsche/schwache/private Konfigurationsschlüssel, manipulierte Kopien/Blobs/IDs, ausbleibende Klartextausgabe, öffentliche API-Abgrenzung, fehlende/ungültige Serverkonfiguration, Nichtüberschreiben, Pfadgrenzen und Legacy-Kompatibilität. Ein veralteter Übersetzungstest wurde an den neuen Sicherheitshinweis angepasst. Bekannte Vite-Chunkwarnung unverändert. +- Anleitung auf Saschas Desktop: `C:\Users\Sascha\Desktop\MDD-Online-Schluessel-Wiederherstellung.md`; versionierte technische Anleitung in `services/backup-api/README.md`. Nächster Schritt vor Client-Release: produktiven Bestand lesen und sichern, Sicherung verifizieren, Einrichtung mit Testdaten prüfen, konkrete Deployment-/Neustartfreigabe einholen, Schlüsselpaar außerhalb des Datenverzeichnisses erstellen und getrennt sichern, Server zuerst aktualisieren/konfigurieren. Keine Live-Daten oder privaten Produktivschlüssel wurden in dieser Sitzung geändert oder erzeugt. Download- und Entpacklogik unverändert. + - Release `v2.0.90` am 6. September 2026 nach ausdrücklicher Freigabe auf GitHub und Forgejo veröffentlicht: Account-Prioritätsmodus und optionaler Zwischenablage-Tabwechsel, Quellbasis `dd4a39e`, Release-Commit `f3f9514177ee68eab7d477fac49b1887b229fa65`. GitHub-Changelog Englisch, Forgejo-Changelog Deutsch. Keine produktive Installation und kein App-/Serverneustart. - Veröffentlichungsprüfung `v2.0.90`: Tag auf beiden Remotes exakt `f3f9514177ee68eab7d477fac49b1887b229fa65`; beide Plattformen führen denselben Titel und Tag als neuesten stabilen Release. Alle zwölf öffentlichen Assets vollständig erneut heruntergeladen und Größe sowie SHA-256 mit lokalen Originalen verglichen. Beide `latest.yml` bestätigen Version und Installer-SHA-512. Setup-SHA-256 `5d40b1c253525473c6398c6290e8a81da1a310ead3320f0b03a4932182d809fd`, Portable-SHA-256 `0f1c6adb137ab69cd33ebca52b6c25e100306f2aabe51194a0f2257b49bb55cc`. Quellarchiv mit 7-Zip geprüft, entspricht dem Tag-Commit. Lokale Originale unter `release/staging-v2.0.90`. - Release-Vorprüfung `v2.0.90`: Vollständiger Clientlauf erneut erfolgreich (2.774 bestanden, vier optionale Tests übersprungen), separat 22 Release-Metadatentests bestanden bei zwei ausgenommenen Windows-Symlink-Fixtures; insgesamt 2.796 Client-Tests. Alle 16 Backup-API-Tests, TypeScript, Self-Check und Produktionsbuilds erfolgreich. Beide isolierten Electron-Browserproben erneut bestanden, Account-Prioritäten in Dark/Light visuell geprüft. Installer und Portable gebaut, EXE-Version 2.0.90 bestätigt; `verify_public_release.mjs --verify-archives` bestätigt beide Archive samt Lizenzdateien und Update-Metadaten. Bekannte Vite-Chunkgrößenwarnung unverändert. diff --git a/services/backup-api/README.md b/services/backup-api/README.md index fa44b7d..2b7aca3 100644 --- a/services/backup-api/README.md +++ b/services/backup-api/README.md @@ -1,6 +1,6 @@ # Multi-Debrid Backup API -Die API speichert ausschließlich bereits clientseitig verschlüsselte, undurchsichtige Backups. Schlüssel und Klartext verlassen den Client nicht. +Die API speichert clientseitig verschlüsselte Backups. Neue Clients hinterlegen zusätzlich den Online-Schlüssel als RSA-3072-OAEP-SHA-256-verschlüsselte Kopie. Der Dienst erhält ausschließlich den öffentlichen Wiederherstellungsschlüssel; der private Schlüssel bleibt außerhalb des Datenverzeichnisses und des Dienstprozesses. Mit diesem privaten Schlüssel kann der Betreiber Online-Schlüssel und damit auch die gespeicherten Zugangsdaten wiederherstellen. Es handelt sich daher nicht um eine ausschließlich für den Nutzer entschlüsselbare Sicherung. Jeder Export wird als eigener unveränderlicher Datensatz gespeichert. Es gibt keine automatische Ablaufzeit und ein neuer Export überschreibt oder löscht keine älteren Sicherungen. @@ -11,6 +11,7 @@ Jeder Export wird als eigener unveränderlicher Datensatz gespeichert. Es gibt k | `HOST` | `127.0.0.1` | Bind-Adresse | | `PORT` | `8787` | HTTP-Port hinter einem TLS-Reverse-Proxy | | `BACKUP_DATA_DIR` | `./data` | Persistentes Datenverzeichnis | +| `RECOVERY_PUBLIC_KEY_FILE` | leer | PEM-Datei mit öffentlichem RSA-3072-Wiederherstellungsschlüssel | | `ALLOWED_ORIGINS` | leer | Kommagetrennte erlaubte Browser-Origins | | `RATE_LIMIT_MAX` | `60` | Maximalzahl pro IP und Zeitfenster | | `RATE_LIMIT_WINDOW_MS` | `60000` | Länge des Zeitfensters | @@ -33,4 +34,38 @@ Der Dienst sollte nur hinter einem TLS-Reverse-Proxy öffentlich erreichbar sein ## HTTP-Vertrag -`POST /v1/backups` akzeptiert `id`, `blob` und `deleteVerifier`. `POST /v1/backups/restore` akzeptiert `id` und liefert ausschließlich `blob`. `POST /v1/backups/delete` akzeptiert `id` und `deleteSecret`. Fehlerhafte Löschgeheimnisse und unbekannte IDs sind nicht unterscheidbar. IDs erscheinen nie in URLs. +`POST /v1/backups/recovery-key` liefert Version, Fingerabdruck und öffentlichen Wiederherstellungsschlüssel oder HTTP 503 bei fehlender Konfiguration. `POST /v1/backups` akzeptiert `id`, `blob`, `deleteVerifier` und optional `recovery` (Version 1, `keyId`, RSA-Chiffrat). Ältere Clients bleiben kompatibel; neue App-Exporte verlangen eine funktionierende Wiederherstellungskonfiguration und fallen nicht still auf ungesicherte Schlüssel zurück. Die zusätzliche Kopie wird atomar mit dem Backup gespeichert und zusammen damit gelöscht. OAEP bindet sie an Backup-ID, Blob-Hash und Löschverifikator. `POST /v1/backups/restore` akzeptiert `id` und liefert ausschließlich `blob`, niemals die Schlüsselkopie. `POST /v1/backups/delete` akzeptiert `id` und `deleteSecret`. Fehlerhafte Löschgeheimnisse und unbekannte IDs sind nicht unterscheidbar. IDs erscheinen nie in URLs. Es gibt keinen HTTP-Endpunkt zum Auslesen oder Auflisten von Online-Schlüsseln. + +## Administrative Schlüsselwiederherstellung + +Vor der produktiven Aktivierung: Datenbestand sichern und Export verifizieren, Ablauf mit Testdaten prüfen, Deployment und Dienstneustart ausdrücklich freigeben lassen. Zuerst Server aktualisieren und konfigurieren, dann den neuen Client verteilen. Bestehende Backups werden nicht verändert; ohne ursprünglich hinterlegte Schlüsselkopie ist keine nachträgliche Wiederherstellung möglich. + +Die folgenden Linux-Pfade sind Beispiele und müssen an die tatsächliche Installation angepasst werden. Befehle im Verzeichnis `services/backup-api` ausführen. + +1. Als Administrator ein neues, noch nicht vorhandenes Schlüsselverzeichnis außerhalb des Backup-Datenverzeichnisses anlegen: + + ```sh + node src/recovery-admin.mjs init /root/mdd-recovery-keys + ``` + + Es entstehen `private.pem` und `public.pem`, Dateien mit Modus 0600 und Verzeichnis 0700. Der Befehl überschreibt keine vorhandenen Schlüssel. Unter Windows müssen zusätzlich restriktive NTFS-ACLs gesetzt werden; Unix-Dateimodi allein schützen dort nicht ausreichend. + +2. `private.pem` separat und verschlüsselt sichern und die Wiederherstellung mit dieser Kopie prüfen. Weder ins Repository noch in das Backup-Datenverzeichnis oder ein Support-Bundle legen. Der Webdienst darf keinen Lesezugriff darauf erhalten. Nur `public.pem` in ein für den Dienst lesbares Konfigurationsverzeichnis kopieren und `RECOVERY_PUBLIC_KEY_FILE` auf dessen absoluten Pfad setzen. Ein ungültiger konfigurierter Schlüssel verhindert den Dienststart. Ohne konfigurierte Datei bleiben alte Clients nutzbar, neue Exporte schlagen gezielt fehl. + +3. Sicherungen anhand von Datum und ID auflisten (keine Zugangsdaten oder Online-Schlüssel): + + ```sh + node src/recovery-admin.mjs list /var/lib/mdd-backups + ``` + + `recoveryKeyId: null` bedeutet: keine Schlüsselkopie vorhanden. Bei mehreren Sicherungen dienen Erstellungszeitpunkt und Backup-ID zur Zuordnung; Inhalte oder Accountnamen werden nicht aufgelistet. + +4. Als Administrator den Schlüssel in eine neue Datei außerhalb des Datenverzeichnisses schreiben: + + ```sh + node src/recovery-admin.mjs recover /var/lib/mdd-backups BACKUP_ID /root/mdd-recovery-keys/private.pem /root/mdd-online-key.txt + ``` + + `BACKUP_ID` durch die ausgewählte ID ersetzen. Der Befehl prüft Schlüssel-Fingerabdruck, OAEP-Bindung, MDD2-Prüfsumme, Löschverifikator und AES-GCM-Authentizität des Backups. Er überschreibt keine Datei und gibt den Schlüssel nicht im Terminal aus. Die Ausgabedatei enthält den vollständigen MDD2-Schlüssel und ist wie ein Passwort zu behandeln. Sicher auf den eigenen Rechner übertragen, in MDD unter „Online-Schlüssel importieren“ verwenden und nicht in Chats oder Logs kopieren. + +Schlüsselrotation: Alte private Schlüssel sicher behalten, solange zugehörige Sicherungen existieren. Neue öffentliche Schlüssel gelten nur für neue Exporte; vorhandene Kopien werden nicht umgeschrieben. Bei Verlust des privaten Schlüssels sind die dazugehörigen Schlüsselkopien nicht wiederherstellbar. Der Dienst benötigt den privaten Schlüssel auch nach der Einrichtung nicht. diff --git a/services/backup-api/src/cli.mjs b/services/backup-api/src/cli.mjs index 7419425..2b0b577 100644 --- a/services/backup-api/src/cli.mjs +++ b/services/backup-api/src/cli.mjs @@ -1,4 +1,5 @@ import { resolve } from 'node:path' +import { readFileSync } from 'node:fs' import { createBackupServer } from './server.mjs' const port = Number.parseInt(process.env.PORT ?? '8787', 10) @@ -21,7 +22,10 @@ const trustedProxy = process.env.TRUST_PROXY === 'true' if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT') -const server = createBackupServer({ rootDir, allowedOrigins, rateLimit, uploadRateLimit, maxStorageBytes, trustedProxy }) +const recoveryPublicKey = process.env.RECOVERY_PUBLIC_KEY_FILE + ? readFileSync(resolve(process.env.RECOVERY_PUBLIC_KEY_FILE), 'utf8') + : undefined +const server = createBackupServer({ rootDir, allowedOrigins, rateLimit, uploadRateLimit, maxStorageBytes, trustedProxy, recoveryPublicKey }) server.listen(port, host, () => { process.stdout.write(`Backup API listening on ${host}:${port}\n`) diff --git a/services/backup-api/src/recovery-admin.mjs b/services/backup-api/src/recovery-admin.mjs new file mode 100644 index 0000000..8bd014b --- /dev/null +++ b/services/backup-api/src/recovery-admin.mjs @@ -0,0 +1,63 @@ +import { generateKeyPairSync } from 'node:crypto' +import { mkdir, open, readFile, readdir, realpath } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { recoverOnlineKey, recoveryDescriptor } from './recovery.mjs' + +async function writeExclusive(file, text) { + const handle = await open(file, 'wx', 0o600) + try { + await handle.writeFile(text, 'utf8') + await handle.sync() + } finally { + await handle.close() + } +} + +async function run() { + const [command, ...args] = process.argv.slice(2) + if (command === 'init' && args.length === 1) { + const directory = resolve(args[0]) + await mkdir(directory, { mode: 0o700 }) + const pair = generateKeyPairSync('rsa', { + modulusLength: 3072, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } + }) + await writeExclusive(join(directory, 'private.pem'), pair.privateKey) + await writeExclusive(join(directory, 'public.pem'), pair.publicKey) + process.stdout.write(`Recovery key ID: ${recoveryDescriptor(pair.publicKey).keyId}\n`) + return + } + if (command === 'list' && args.length === 1) { + const directory = resolve(args[0]) + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isFile() || !/^[A-Za-z0-9_-]{22}\.json$/.test(entry.name)) continue + const record = JSON.parse(await readFile(join(directory, entry.name), 'utf8')) + process.stdout.write(JSON.stringify({ id: entry.name.slice(0, -5), createdAt: record.createdAt, recoveryKeyId: record.recovery?.keyId ?? null }) + '\n') + } + return + } + if (command === 'recover' && args.length === 4) { + const [root, id, privateFile, output] = args + if (!/^[A-Za-z0-9_-]{22}$/.test(id) || Buffer.from(id, 'base64url').toString('base64url') !== id) throw new Error() + const directory = await realpath(root) + const privatePath = await realpath(privateFile) + const outputPath = join(await realpath(dirname(resolve(output))), basename(output)) + for (const target of [privatePath, outputPath]) { + const rel = relative(directory, target) + if (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..\\') && !rel.startsWith('../')) throw new Error() + } + const record = JSON.parse(await readFile(join(directory, `${id}.json`), 'utf8')) + const key = recoverOnlineKey({ ...record, id }, await readFile(privatePath, 'utf8')) + await writeExclusive(outputPath, key + '\n') + process.stdout.write('Online key written to the requested file. Protect it like a password.\n') + return + } + process.stderr.write('Usage: recovery-admin.mjs init | list | recover \n') + process.exitCode = 1 +} + +run().catch(() => { + process.stderr.write('Recovery operation failed. Check arguments, permissions, key pairing and backup integrity. Existing files are never overwritten.\n') + process.exitCode = 1 +}) diff --git a/services/backup-api/src/recovery.d.mts b/services/backup-api/src/recovery.d.mts new file mode 100644 index 0000000..38f4106 --- /dev/null +++ b/services/backup-api/src/recovery.d.mts @@ -0,0 +1,2 @@ +export function recoveryDescriptor(pem: string): { version: number; keyId: string; publicKey: string }; +export function recoverOnlineKey(record: { id: string; blob: string; deleteVerifier: string; recovery?: unknown }, privateKey: string): string; diff --git a/services/backup-api/src/recovery.mjs b/services/backup-api/src/recovery.mjs new file mode 100644 index 0000000..4f04f82 --- /dev/null +++ b/services/backup-api/src/recovery.mjs @@ -0,0 +1,46 @@ +import { constants, createDecipheriv, createHash, createPublicKey, hkdfSync, privateDecrypt, timingSafeEqual } from 'node:crypto' + +export function recoveryDescriptor(pem) { + if (typeof pem !== 'string' || !pem.startsWith('-----BEGIN PUBLIC KEY-----')) throw new Error('Expected a public recovery key') + const key = createPublicKey(pem) + if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails?.modulusLength !== 3072) throw new Error('Recovery requires RSA-3072') + return { + version: 1, + keyId: createHash('sha256').update(key.export({ type: 'spki', format: 'der' })).digest('base64url'), + publicKey: key.export({ type: 'spki', format: 'pem' }).toString() + } +} + +export function recoveryLabel(record) { + return Buffer.from(`MDD-RECOVERY-V1:${record.id}:${record.deleteVerifier}:${createHash('sha256').update(record.blob).digest('base64url')}`) +} + +export function validRecoveryEnvelope(envelope, descriptor) { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return false + if (Object.keys(envelope).sort().join(',') !== 'ciphertext,keyId,version') return false + return envelope.version === 1 && envelope.keyId === descriptor?.keyId + && typeof envelope.ciphertext === 'string' && /^[A-Za-z0-9_-]{512}$/.test(envelope.ciphertext) + && Buffer.from(envelope.ciphertext, 'base64url').length === 384 +} + +export function recoverOnlineKey(record, privateKey) { + const publicKey = createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString() + if (!validRecoveryEnvelope(record.recovery, recoveryDescriptor(publicKey))) throw new Error('No recovery envelope for this private key') + const key = privateDecrypt({ key: privateKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256', oaepLabel: recoveryLabel(record) }, Buffer.from(record.recovery.ciphertext, 'base64url')).toString('utf8') + if (!/^MDD2-[A-Za-z0-9_-]{70}$/.test(key)) throw new Error('Invalid recovered key') + const decoded = Buffer.from(key.slice(5), 'base64url') + const id = decoded.subarray(0, 16) + const master = decoded.subarray(16, 48) + const checksum = createHash('sha256').update('MDD2-ONLINE-KEY-V1').update(id).update(master).digest().subarray(0, 4) + if (decoded.toString('base64url') !== key.slice(5) || id.toString('base64url') !== record.id || !timingSafeEqual(checksum, decoded.subarray(48))) throw new Error('Recovered key does not match record') + const secret = purpose => Buffer.from(hkdfSync('sha256', master, id, Buffer.from(`MDD-ONLINE-${purpose}-V1`), 32)) + if (createHash('sha256').update(secret('DELETE')).digest('base64url') !== record.deleteVerifier) throw new Error('Invalid deletion verifier') + const blob = Buffer.from(record.blob, 'base64url') + if (blob[0] !== 1 || blob.length < 29) throw new Error('Invalid backup blob') + const decipher = createDecipheriv('aes-256-gcm', secret('ENCRYPTION'), blob.subarray(1, 13)) + decipher.setAAD(Buffer.concat([Buffer.from('MDD-ONLINE-BACKUP-V1'), id])) + decipher.setAuthTag(blob.subarray(13, 29)) + decipher.update(blob.subarray(29)) + decipher.final() + return key +} diff --git a/services/backup-api/src/server.d.mts b/services/backup-api/src/server.d.mts index 5e23c99..4798483 100644 --- a/services/backup-api/src/server.d.mts +++ b/services/backup-api/src/server.d.mts @@ -2,6 +2,7 @@ import type { Server } from "node:http"; export interface BackupServerOptions { rootDir: string; + recoveryPublicKey?: string; allowedOrigins?: string[]; rateLimit?: { max: number; diff --git a/services/backup-api/src/server.mjs b/services/backup-api/src/server.mjs index 834f88f..b7c71f5 100644 --- a/services/backup-api/src/server.mjs +++ b/services/backup-api/src/server.mjs @@ -4,6 +4,7 @@ import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/prom import { isIP } from 'node:net' import { join } from 'node:path' import lockfile from 'proper-lockfile' +import { recoveryDescriptor, validRecoveryEnvelope } from './recovery.mjs' const maxBlobBytes = 256 * 1024 const maxBodyBytes = 384 * 1024 @@ -18,10 +19,11 @@ function isCanonicalBase64Url(value, byteLength, pattern) { return decoded.length === byteLength && decoded.toString('base64url') === value } -function isValidBackup(payload) { +function isValidBackup(payload, recovery) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false const keys = Object.keys(payload).sort() - if (keys.join(',') !== 'blob,deleteVerifier,id') return false + if (!['blob,deleteVerifier,id', 'blob,deleteVerifier,id,recovery'].includes(keys.join(','))) return false + if ('recovery' in payload && !validRecoveryEnvelope(payload.recovery, recovery)) return false if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false @@ -187,6 +189,7 @@ async function createRecord(rootDir, payload, maxStorageBytes) { version: 1, blob: payload.blob, deleteVerifier: payload.deleteVerifier, + ...(payload.recovery ? { recovery: payload.recovery } : {}), createdAt: new Date().toISOString() }), 'utf8') if (await directoryUsage(rootDir) + contents.length > maxStorageBytes) return 'full' @@ -314,6 +317,7 @@ function clientAddress(request, trustedProxy) { export function createBackupServer(options) { if (!options?.rootDir) throw new Error('rootDir is required') const allowedOrigins = new Set(options.allowedOrigins ?? []) + const recovery = options.recoveryPublicKey ? recoveryDescriptor(options.recoveryPublicKey) : null const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 } const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 } const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024 @@ -367,6 +371,12 @@ export function createBackupServer(options) { } } + if (request.method === 'POST' && url.pathname === '/v1/backups/recovery-key') { + request.resume() + sendJson(response, recovery ? 200 : 503, recovery ?? { error: 'recovery_unavailable' }) + return + } + if (request.method === 'POST' && ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete'].includes(url.pathname)) { if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') { sendJson(response, 415, { error: 'unsupported_media_type' }) @@ -428,7 +438,7 @@ export function createBackupServer(options) { sendJson(response, 413, { error: 'payload_too_large' }) return } - if (!isValidBackup(parsed.value)) { + if (!isValidBackup(parsed.value, recovery)) { sendJson(response, 400, { error: 'invalid_request' }) return } diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 589295d..ab6ab5c 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1457,7 +1457,7 @@ export class AppController { public async exportOnlineBackup(): Promise<{ key: string }> { const proxyListContent = captureOnlineProxyList(this.settings); const created = createOnlineBackup({ ...this.settings }, APP_VERSION, undefined, proxyListContent); - await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL); + await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL, created.key); this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only", proxyListIncluded: proxyListContent !== undefined }); return { key: created.key }; } diff --git a/src/main/online-backup.ts b/src/main/online-backup.ts index 9050105..cd3da40 100644 --- a/src/main/online-backup.ts +++ b/src/main/online-backup.ts @@ -250,11 +250,37 @@ export function restoreOnlineBackup(key: string, blob: string): OnlineSettingsPa } } -export async function uploadOnlineBackup(record: OnlineBackupRecord, baseUrl: string): Promise { +export async function uploadOnlineBackup(record: OnlineBackupRecord, baseUrl: string, recoveryKey?: string): Promise { + let recovery: { version: number; keyId: string; ciphertext: string } | undefined; + if (recoveryKey !== undefined) { + if (parseOnlineBackupKey(recoveryKey).id !== record.id) throw new Error("Online-Schlüssel passt nicht zur Sicherung"); + restoreOnlineBackup(recoveryKey, record.blob); + const descriptorResponse = await request(endpoint(baseUrl, "/v1/backups/recovery-key"), { method: "POST" }); + const descriptorBody = await readLimitedText(descriptorResponse); + if (descriptorResponse.status !== 200) throw new Error("Schlüsselwiederherstellung ist auf dem Sicherungsserver nicht eingerichtet"); + try { + const descriptor = JSON.parse(descriptorBody); + if (descriptor.version !== 1 || typeof descriptor.publicKey !== "string" || !descriptor.publicKey.startsWith("-----BEGIN PUBLIC KEY-----")) throw new Error(); + const publicKey = crypto.createPublicKey(descriptor.publicKey); + if (publicKey.asymmetricKeyType !== "rsa" || publicKey.asymmetricKeyDetails?.modulusLength !== 3072) throw new Error(); + const keyId = crypto.createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("base64url"); + if (descriptor.keyId !== keyId) throw new Error(); + const blobHash = crypto.createHash("sha256").update(record.blob).digest("base64url"); + const ciphertext = crypto.publicEncrypt({ + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + oaepLabel: Buffer.from(`MDD-RECOVERY-V1:${record.id}:${record.deleteVerifier}:${blobHash}`) + }, Buffer.from(recoveryKey.trim(), "utf8")).toString("base64url"); + recovery = { version: 1, keyId, ciphertext }; + } catch { + throw new Error("Wiederherstellungsschlüssel des Sicherungsservers ist ungültig"); + } + } const response = await request(endpoint(baseUrl, "/v1/backups"), { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, - body: JSON.stringify(record) + body: JSON.stringify({ ...record, ...(recovery ? { recovery } : {}) }) }); await readLimitedText(response); if (response.status !== 201) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 911eb1a..be7dfbf 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4970,7 +4970,7 @@ export function App(): ReactElement { setOnlineBackupDialog({ mode: "export", key: result.key, busy: false, error: "" }); showToast("Online-Schlüssel erstellt", 2600); } catch { - setOnlineBackupDialog({ mode: "export", key: "", busy: false, error: "Online-Sicherung konnte nicht erstellt werden." }); + setOnlineBackupDialog({ mode: "export", key: "", busy: false, error: "Online-Sicherung konnte nicht erstellt werden. Verbindung und serverseitige Schlüsselwiederherstellung prüfen." }); } }; @@ -6858,7 +6858,7 @@ export function App(): ReactElement { >

{onlineBackupDialog.mode === "export" - ? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf." + ? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf. Eine verschlüsselte Schlüsselkopie ermöglicht dem Betreiber, den Schlüssel und damit die Sicherung wiederherzustellen." : "Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt."}

{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy &&
Online-Sicherung wird verschlüsselt und gespeichert …
} diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index bbf8809..2253526 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -165,7 +165,7 @@ const pairs = [ ["Sicherung schützen", "Protect backup"], ["Sicherung entsperren", "Unlock backup"], ["Lege eine Passphrase für diese Sicherung fest. Sie wird nicht gespeichert und wird beim Import erneut benötigt.", "Set a passphrase for this backup. It is not stored and will be required again during import."], ["Diese Sicherung ist mit einer Passphrase geschützt.", "This backup is protected with a passphrase."], ["Passphrase", "Passphrase"], ["Passphrase bestätigen", "Confirm passphrase"], ["Bitte eine Passphrase eingeben", "Enter a passphrase"], ["Die Passphrasen stimmen nicht überein", "The passphrases do not match"], ["Sicherung exportieren", "Export backup"], ["Sicherung importieren", "Import backup"], - ["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."], + ["Online-Sicherung konnte nicht erstellt werden. Verbindung und serverseitige Schlüsselwiederherstellung prüfen.", "Online backup could not be created. Check the connection and server-side key recovery configuration."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."], ["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"], ["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"], ["Remote-fähig konfiguriert", "Configured for remote access"], ["Debug-Setup prüfen", "Check debug setup"], ["Letzte Fehler", "Recent errors"], ["Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet.", "No errors or warnings recorded since the app started."], @@ -185,7 +185,7 @@ const pairs = [ ["Text mit Links analysieren", "Analyze text containing links"], ["Online-Schlüssel exportieren", "Export online key"], ["Online-Schlüssel importieren", "Import online key"], ["Logs öffnen", "Open logs"], ["Support-Bundle exportieren", "Export support bundle"], ["Support-Trace deaktivieren", "Disable support trace"], ["Support-Trace aktivieren", "Enable support trace"], ["Letzte Fehler anzeigen", "Show recent errors"], ["Einträge:", "Entries:"], ["Ausgewählt:", "Selected:"], ["Online-Schlüssel", "Online key"], - ["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials and the configured proxy list. Keep it as secure as a password."], + ["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf. Eine verschlüsselte Schlüsselkopie ermöglicht dem Betreiber, den Schlüssel und damit die Sicherung wiederherzustellen.", "This key restores your settings, including saved credentials and the configured proxy list. Keep it as secure as a password. An encrypted copy allows the operator to recover the key and access the backup."], ["Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. Settings and any included proxy list will be replaced by the stored version."], ["Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt", "Settings and proxy list restored from online backup"], ["Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält", "Settings restored; Proxy-only was disabled because the online backup contains no proxy list"], diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 8e2824b..02236de 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -55,7 +55,8 @@ describe("renderer localization", () => { }); it.each([ - ["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials and the configured proxy list. Keep it as secure as a password."], + ["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf. Eine verschlüsselte Schlüsselkopie ermöglicht dem Betreiber, den Schlüssel und damit die Sicherung wiederherzustellen.", "This key restores your settings, including saved credentials and the configured proxy list. Keep it as secure as a password. An encrypted copy allows the operator to recover the key and access the backup."], + ["Online-Sicherung konnte nicht erstellt werden. Verbindung und serverseitige Schlüsselwiederherstellung prüfen.", "Online backup could not be created. Check the connection and server-side key recovery configuration."], ["Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. Settings and any included proxy list will be replaced by the stored version."], ["Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt", "Settings and proxy list restored from online backup"], ["Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält", "Settings restored; Proxy-only was disabled because the online backup contains no proxy list"] diff --git a/tests/online-backup-service.test.ts b/tests/online-backup-service.test.ts index e81c68e..02756a5 100644 --- a/tests/online-backup-service.test.ts +++ b/tests/online-backup-service.test.ts @@ -1,10 +1,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { generateKeyPairSync } from "node:crypto"; +import { spawnSync } from "node:child_process"; import { once } from "node:events"; import { afterEach, describe, expect, it } from "vitest"; import type { AppSettings } from "../src/shared/types"; import { createBackupServer } from "../services/backup-api/src/server.mjs"; +import { recoverOnlineKey } from "../services/backup-api/src/recovery.mjs"; import { createOnlineBackup, deleteOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "../src/main/online-backup"; const servers: ReturnType[] = []; @@ -16,6 +19,92 @@ afterEach(async () => { }); describe("online backup client and service", () => { + it("rejects private or weak recovery keys in the service configuration", () => { + const pair = generateKeyPairSync("rsa", { modulusLength: 2048, publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" } }); + expect(() => createBackupServer({ rootDir: "unused", recoveryPublicKey: pair.privateKey })).toThrow(/public/); + expect(() => createBackupServer({ rootDir: "unused", recoveryPublicKey: pair.publicKey })).toThrow(/3072/); + }); + + it("stores only an encrypted recovery key and recovers it with the separate admin key", async () => { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-recovery-")); + directories.push(directory); + const rootDir = path.join(directory, "data"); + const pair = generateKeyPairSync("rsa", { + modulusLength: 3072, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }); + const server = createBackupServer({ rootDir, recoveryPublicKey: pair.publicKey }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + const baseUrl = `http://127.0.0.1:${address.port}`; + const settings = { token: "recovery-secret", outputDir: "D:\\Downloads" } as AppSettings; + const created = createOnlineBackup(settings, "2.0.90"); + await uploadOnlineBackup(created.record, baseUrl, created.key); + const raw = await fs.promises.readFile(path.join(rootDir, `${created.record.id}.json`), "utf8"); + expect(raw).not.toContain(created.key); + expect(raw).not.toContain("recovery-secret"); + expect(raw).not.toContain(pair.privateKey); + const stored = { ...JSON.parse(raw), id: created.record.id }; + expect(recoverOnlineKey(stored, pair.privateKey)).toBe(created.key); + expect(() => recoverOnlineKey({ ...stored, blob: stored.blob + "A" }, pair.privateKey)).toThrow(); + expect(() => recoverOnlineKey({ ...stored, id: "AAAAAAAAAAAAAAAAAAAAAA" }, pair.privateKey)).toThrow(); + expect(() => recoverOnlineKey({ ...stored, recovery: undefined }, pair.privateKey)).toThrow(); + const wrongPair = generateKeyPairSync("rsa", { modulusLength: 3072, privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } }); + expect(() => recoverOnlineKey(stored, wrongPair.privateKey)).toThrow(); + const corrupted = { ...stored.recovery, ciphertext: (stored.recovery.ciphertext[0] === "A" ? "B" : "A") + stored.recovery.ciphertext.slice(1) }; + expect(() => recoverOnlineKey({ ...stored, recovery: corrupted }, pair.privateKey)).toThrow(); + const restore = await fetch(`${baseUrl}/v1/backups/restore`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: stored.id }) }); + expect(await restore.json()).toEqual({ blob: stored.blob }); + expect((await downloadOnlineBackup(created.key, baseUrl)).settings).toEqual(settings); + expect((await fetch(`${baseUrl}/v1/backups/recover`, { method: "POST" })).status).toBe(404); + const rejected = await fetch(`${baseUrl}/v1/backups`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...created.record, recovery: { ...stored.recovery, keyId: "wrong-key" } }) }); + expect(rejected.status).toBe(400); + const privateFile = path.join(directory, "private.pem"); + const outputFile = path.join(directory, "recovered.txt"); + await fs.promises.writeFile(privateFile, pair.privateKey, { mode: 0o600 }); + const cli = path.resolve("services/backup-api/src/recovery-admin.mjs"); + const run = (...args: string[]) => spawnSync(process.execPath, [cli, ...args], { encoding: "utf8" }); + const listed = run("list", rootDir); + expect(listed.status).toBe(0); + expect(JSON.parse(listed.stdout).id).toBe(stored.id); + expect(listed.stdout).not.toContain(created.key); + const recovered = run("recover", rootDir, stored.id, privateFile, outputFile); + expect(recovered.status).toBe(0); + expect(recovered.stdout + recovered.stderr).not.toContain(created.key); + expect((await fs.promises.readFile(outputFile, "utf8")).trim()).toBe(created.key); + expect(run("recover", rootDir, stored.id, privateFile, outputFile).status).toBe(1); + expect(run("recover", rootDir, "../invalid", privateFile, outputFile).status).toBe(1); + expect(run("recover", rootDir, stored.id, privateFile, path.join(rootDir, "leaked.txt")).status).toBe(1); + const keyDir = path.join(directory, "new-keys"); + expect(run("init", keyDir).status).toBe(0); + const initialPrivate = await fs.promises.readFile(path.join(keyDir, "private.pem"), "utf8"); + expect(run("init", keyDir).status).toBe(1); + expect(await fs.promises.readFile(path.join(keyDir, "private.pem"), "utf8")).toBe(initialPrivate); + await deleteOnlineBackup(created.key, baseUrl); + expect(fs.existsSync(path.join(rootDir, `${stored.id}.json`))).toBe(false); + }); + + it("does not silently upload without recovery when the service is not configured", async () => { + const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-no-recovery-")); + directories.push(rootDir); + const server = createBackupServer({ rootDir }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + const baseUrl = `http://127.0.0.1:${address.port}`; + const created = createOnlineBackup({ token: "secret" } as AppSettings, "2.0.90"); + await expect(uploadOnlineBackup(created.record, baseUrl, created.key)).rejects.toThrow(/nicht eingerichtet/); + expect(await fs.promises.readdir(rootDir)).toEqual([]); + await uploadOnlineBackup(created.record, baseUrl); + expect((await downloadOnlineBackup(created.key, baseUrl)).settings.token).toBe("secret"); + }); + it("keeps every export independently restorable and deletes only the selected snapshot", async () => { const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-online-backup-")); directories.push(rootDir); diff --git a/tests/online-backup.test.ts b/tests/online-backup.test.ts index a770a1d..b83ea81 100644 --- a/tests/online-backup.test.ts +++ b/tests/online-backup.test.ts @@ -29,6 +29,24 @@ function settings(): AppSettings { } describe("online backup key", () => { + it("fails closed on an invalid recovery descriptor without uploading a backup", async () => { + const urls: string[] = []; + const server = http.createServer((request, response) => { + urls.push(request.url || ""); + request.resume(); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: 1, keyId: "invalid", publicKey: "server-secret-value" })); + }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + const created = createOnlineBackup(settings(), "2.0.90"); + await expect(uploadOnlineBackup(created.record, `http://127.0.0.1:${address.port}`, created.key)).rejects.toThrow("Wiederherstellungsschlüssel des Sicherungsservers ist ungültig"); + expect(urls).toEqual(["/v1/backups/recovery-key"]); + }); + it("creates a compact key and restores every immutable settings snapshot independently", () => { const first = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z"); const second = createOnlineBackup({ ...settings(), outputDir: "E:\\Neu" }, "2.0.0", "2026-08-08T00:00:00.000Z");