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
+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
}