Redesign the desktop workspace with task sidebars, live filters, clearer settings, and an accessible update dialog. Harden encrypted backup imports, configuration persistence, history retention, queue snapshots, shutdown recovery, and update installation ordering. Publish verified Windows artifacts and refreshed English documentation.
252 lines
9.9 KiB
JavaScript
252 lines
9.9 KiB
JavaScript
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');
|
|
|
|
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 createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString()) {
|
|
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
|
|
}
|
|
};
|
|
}
|
|
|
|
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) {
|
|
throw new Error(response.status === 404 ? 'Online-Sicherung wurde nicht gefunden' : 'Online-Sicherung konnte nicht gelöscht werden');
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
ONLINE_BACKUP_API_URL,
|
|
createOnlineBackup,
|
|
deleteOnlineBackup,
|
|
downloadOnlineBackup,
|
|
parseOnlineBackupKey,
|
|
restoreOnlineBackup,
|
|
uploadOnlineBackup
|
|
};
|