Add encrypted administrative recovery for online backup keys

This commit is contained in:
Sucukdeluxe
2026-09-22 04:34:27 +02:00
parent 5e15d4f864
commit 7f6adac78b
16 changed files with 318 additions and 14 deletions
+3
View File
@@ -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
+6
View File
@@ -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 <neues-verzeichnis>`, `list <datenverzeichnis>` und `recover <datenverzeichnis> <id> <private.pem> <neue-ausgabedatei>`. 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.
+37 -2
View File
@@ -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.
+5 -1
View File
@@ -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`)
@@ -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 <new-key-directory> | list <backup-data-directory> | recover <backup-data-directory> <backup-id> <private.pem> <new-output-file>\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
})
+2
View File
@@ -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;
+46
View File
@@ -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
}
+1
View File
@@ -2,6 +2,7 @@ import type { Server } from "node:http";
export interface BackupServerOptions {
rootDir: string;
recoveryPublicKey?: string;
allowedOrigins?: string[];
rateLimit?: {
max: number;
+13 -3
View File
@@ -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
}
+1 -1
View File
@@ -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 };
}
+28 -2
View File
@@ -250,11 +250,37 @@ export function restoreOnlineBackup(key: string, blob: string): OnlineSettingsPa
}
}
export async function uploadOnlineBackup(record: OnlineBackupRecord, baseUrl: string): Promise<void> {
export async function uploadOnlineBackup(record: OnlineBackupRecord, baseUrl: string, recoveryKey?: string): Promise<void> {
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) {
+2 -2
View File
@@ -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 {
>
<p>
{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."}
</p>
{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy && <div className="online-backup-status">Online-Sicherung wird verschlüsselt und gespeichert </div>}
+2 -2
View File
@@ -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"],
+2 -1
View File
@@ -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"]
+89
View File
@@ -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<typeof createBackupServer>[] = [];
@@ -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);
+18
View File
@@ -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");