286 lines
11 KiB
JavaScript
286 lines
11 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');
|
|
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
|
|
};
|