const crypto = require('node:crypto'); const zlib = require('node:zlib'); const ONLINE_BACKUP_API_URL = 'https://uploader.24-music.de/backup-api'; const KEY_PREFIX = 'MHU2-'; const KEY_BODY_LENGTH = 70; const RECORD_ID_LENGTH = 16; const MASTER_KEY_LENGTH = 32; const CHECKSUM_LENGTH = 4; const NONCE_LENGTH = 12; const AUTH_TAG_LENGTH = 16; const BLOB_VERSION = 1; const MAX_BLOB_BYTES = 256 * 1024; const MAX_RESPONSE_BYTES = 512 * 1024; const MAX_PLAINTEXT_BYTES = 512 * 1024; const REQUEST_TIMEOUT_MS = 12_000; const KEY_CONTEXT = Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8'); const AAD_CONTEXT = Buffer.from('MHU-ONLINE-BACKUP-V1', 'utf8'); const ONLINE_BACKUP_RETENTION_SECONDS = Object.freeze({ '1d': 24 * 60 * 60, '3d': 3 * 24 * 60 * 60, '7d': 7 * 24 * 60 * 60, '31d': 31 * 24 * 60 * 60, forever: null }); const DEFAULT_ONLINE_BACKUP_RETENTION = '7d'; function checksum(idBytes, masterKey) { return crypto.createHash('sha256').update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH); } function deriveSecret(masterKey, idBytes, purpose) { return Buffer.from(crypto.hkdfSync('sha256', masterKey, idBytes, Buffer.from(`MHU-ONLINE-${purpose}-V1`, 'utf8'), 32)); } function deriveDeleteSecret(parsed) { return deriveSecret(parsed.masterKey, parsed.idBytes, 'DELETE'); } function aad(idBytes) { return Buffer.concat([AAD_CONTEXT, idBytes]); } function encodeKey(idBytes, masterKey) { const body = Buffer.concat([idBytes, masterKey, checksum(idBytes, masterKey)]).toString('base64url'); return `${KEY_PREFIX}${body}`; } function validatePayload(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Online-Sicherung enthält keine gültigen Einstellungen'); } if ( value.version !== 1 || value.kind !== 'settings-only' || typeof value.appVersion !== 'string' || typeof value.exportedAt !== 'string' || !value.settings || typeof value.settings !== 'object' || Array.isArray(value.settings) || Object.prototype.hasOwnProperty.call(value, 'session') || Object.prototype.hasOwnProperty.call(value, 'history') ) { throw new Error('Online-Sicherung enthält keine gültigen Einstellungen'); } return value; } function endpoint(baseUrl, relativePath) { const normalized = String(baseUrl || '').trim().replace(/\/+$/, ''); const url = new URL(`${normalized}${relativePath}`); if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) { throw new Error('Online-Sicherungen benötigen eine sichere HTTPS-Verbindung'); } return url.toString(); } async function requestText(url, init, options = {}) { const controller = new AbortController(); const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS; const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await (options.fetchImpl || fetch)(url, { ...init, signal: controller.signal }); const body = await readLimitedText(response); return { response, body }; } catch { if (controller.signal.aborted) throw new Error('Online-Sicherungsdienst antwortet nicht'); throw new Error('Online-Sicherungsdienst ist nicht erreichbar'); } finally { clearTimeout(timer); } } async function readLimitedText(response) { const contentLength = Number(response.headers.get('content-length') || '0'); if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { throw new Error('Antwort des Online-Sicherungsdienstes ist zu groß'); } if (!response.body) return ''; const reader = response.body.getReader(); const chunks = []; let total = 0; while (true) { const result = await reader.read(); if (result.done) break; total += result.value.byteLength; if (total > MAX_RESPONSE_BYTES) { await reader.cancel(); throw new Error('Antwort des Online-Sicherungsdienstes ist zu groß'); } chunks.push(Buffer.from(result.value)); } return Buffer.concat(chunks).toString('utf8'); } function parseOnlineBackupKey(key) { const normalized = String(key || '').trim(); if (!new RegExp(`^${KEY_PREFIX}[A-Za-z0-9_-]{${KEY_BODY_LENGTH}}$`).test(normalized)) { throw new Error('Online-Sicherungsschlüssel ist ungültig'); } const decoded = Buffer.from(normalized.slice(KEY_PREFIX.length), 'base64url'); if (decoded.length !== RECORD_ID_LENGTH + MASTER_KEY_LENGTH + CHECKSUM_LENGTH) { throw new Error('Online-Sicherungsschlüssel ist ungültig'); } if (decoded.toString('base64url') !== normalized.slice(KEY_PREFIX.length)) { throw new Error('Online-Sicherungsschlüssel ist ungültig'); } const idBytes = decoded.subarray(0, RECORD_ID_LENGTH); const masterKey = decoded.subarray(RECORD_ID_LENGTH, RECORD_ID_LENGTH + MASTER_KEY_LENGTH); const actualChecksum = decoded.subarray(RECORD_ID_LENGTH + MASTER_KEY_LENGTH); const expectedChecksum = checksum(idBytes, masterKey); if (!crypto.timingSafeEqual(actualChecksum, expectedChecksum)) { throw new Error('Online-Sicherungsschlüssel ist beschädigt'); } return { id: idBytes.toString('base64url'), idBytes: Buffer.from(idBytes), masterKey: Buffer.from(masterKey) }; } function normalizeOnlineBackupRetention(value = DEFAULT_ONLINE_BACKUP_RETENTION) { const normalized = String(value || '').trim(); if (!Object.prototype.hasOwnProperty.call(ONLINE_BACKUP_RETENTION_SECONDS, normalized)) { throw new Error('Gültigkeitsdauer der Online-Sicherung ist ungültig'); } return normalized; } function onlineBackupExpiration(exportedAt, retention) { const normalized = normalizeOnlineBackupRetention(retention); const seconds = ONLINE_BACKUP_RETENTION_SECONDS[normalized]; if (seconds === null) return null; const created = new Date(exportedAt); if (!Number.isFinite(created.getTime())) throw new Error('Erstellungszeit der Online-Sicherung ist ungültig'); return new Date(created.getTime() + seconds * 1000).toISOString(); } function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString(), retention = DEFAULT_ONLINE_BACKUP_RETENTION) { const normalizedRetention = normalizeOnlineBackupRetention(retention); const expiresInSeconds = ONLINE_BACKUP_RETENTION_SECONDS[normalizedRetention]; const expiresAt = onlineBackupExpiration(exportedAt, normalizedRetention); const idBytes = crypto.randomBytes(RECORD_ID_LENGTH); const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH); const key = encodeKey(idBytes, masterKey); const encryptionKey = deriveSecret(masterKey, idBytes, 'ENCRYPTION'); const nonce = crypto.randomBytes(NONCE_LENGTH); const payload = { version: 1, kind: 'settings-only', appVersion: String(appVersion || ''), exportedAt, settings: JSON.parse(JSON.stringify(settings)) }; const plaintext = Buffer.from(JSON.stringify(payload), 'utf8'); if (plaintext.length > MAX_PLAINTEXT_BYTES) { throw new Error('Einstellungen sind für eine Online-Sicherung zu groß'); } const compressed = zlib.gzipSync(plaintext, { level: 9 }); const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH }); cipher.setAAD(aad(idBytes)); const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]); const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]); if (blobBytes.length > MAX_BLOB_BYTES) { throw new Error('Einstellungen sind für eine Online-Sicherung zu groß'); } const parsed = parseOnlineBackupKey(key); const deleteVerifier = crypto.createHash('sha256').update(deriveDeleteSecret(parsed)).digest('base64url'); return { key, record: { id: parsed.id, blob: blobBytes.toString('base64url'), deleteVerifier, expiresInSeconds }, expiresAt }; } function restoreOnlineBackup(key, blob) { const parsed = parseOnlineBackupKey(key); if (typeof blob !== 'string' || !/^[A-Za-z0-9_-]+$/.test(blob) || blob.length > Math.ceil(MAX_BLOB_BYTES * 4 / 3) + 4) { throw new Error('Online-Sicherung ist beschädigt'); } const bytes = Buffer.from(blob, 'base64url'); if (bytes.toString('base64url') !== blob || bytes.length < 1 + NONCE_LENGTH + AUTH_TAG_LENGTH || bytes[0] !== BLOB_VERSION) { throw new Error('Online-Sicherung ist beschädigt'); } const nonce = bytes.subarray(1, 1 + NONCE_LENGTH); const tag = bytes.subarray(1 + NONCE_LENGTH, 1 + NONCE_LENGTH + AUTH_TAG_LENGTH); const ciphertext = bytes.subarray(1 + NONCE_LENGTH + AUTH_TAG_LENGTH); try { const decipher = crypto.createDecipheriv( 'aes-256-gcm', deriveSecret(parsed.masterKey, parsed.idBytes, 'ENCRYPTION'), nonce, { authTagLength: AUTH_TAG_LENGTH } ); decipher.setAAD(aad(parsed.idBytes)); decipher.setAuthTag(tag); const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]); const plaintext = zlib.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES }).toString('utf8'); return validatePayload(JSON.parse(plaintext)); } catch (error) { if (error instanceof Error && /keine gültigen Einstellungen/.test(error.message)) throw error; throw new Error('Online-Sicherung konnte nicht entschlüsselt werden oder ist beschädigt'); } } async function uploadOnlineBackup(record, baseUrl = ONLINE_BACKUP_API_URL, options) { const { response } = await requestText(endpoint(baseUrl, '/v1/backups'), { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(record) }, options); if (response.status !== 201) throw new Error('Online-Sicherung konnte nicht gespeichert werden'); } async function downloadOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options) { const parsed = parseOnlineBackupKey(key); const { response, body } = await requestText(endpoint(baseUrl, '/v1/backups/restore'), { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ id: parsed.id }) }, options); if (response.status !== 200) { throw new Error(response.status === 404 ? 'Online-Sicherung wurde nicht gefunden' : 'Online-Sicherung konnte nicht geladen werden'); } let value; try { value = JSON.parse(body); } catch { throw new Error('Online-Sicherungsdienst hat ungültige Daten geliefert'); } if (typeof value?.blob !== 'string') throw new Error('Online-Sicherungsdienst hat ungültige Daten geliefert'); return restoreOnlineBackup(key, value.blob); } async function deleteOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options) { const parsed = parseOnlineBackupKey(key); const deleteSecret = deriveDeleteSecret(parsed).toString('base64url'); const { response } = await requestText(endpoint(baseUrl, '/v1/backups/delete'), { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ id: parsed.id, deleteSecret }) }, options); if (response.status === 204) return { deleted: true, notFound: false }; if (response.status === 404) return { deleted: false, notFound: true }; throw new Error('Online-Sicherung konnte nicht gelöscht werden'); } module.exports = { DEFAULT_ONLINE_BACKUP_RETENTION, ONLINE_BACKUP_API_URL, ONLINE_BACKUP_RETENTION_SECONDS, createOnlineBackup, deleteOnlineBackup, downloadOnlineBackup, normalizeOnlineBackupRetention, onlineBackupExpiration, parseOnlineBackupKey, restoreOnlineBackup, uploadOnlineBackup };