This commit is contained in:
@@ -22,3 +22,5 @@ account-rotation.log
|
|||||||
doodstream-debug.log
|
doodstream-debug.log
|
||||||
upload-debug.log
|
upload-debug.log
|
||||||
release-*.log
|
release-*.log
|
||||||
|
recovery-private.pem
|
||||||
|
recovered-online-key*.txt
|
||||||
|
|||||||
+6
-2
@@ -6,6 +6,9 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große
|
|||||||
|
|
||||||
## Aktueller Zustand
|
## Aktueller Zustand
|
||||||
|
|
||||||
|
- Lokale Erweiterung: Online-Backups erhalten einen RSA-3072/OAEP-SHA256-verschlüsselten Wiederherstellungsumschlag, der an die Datensatzkennung gebunden ist. Der private Wiederherstellungsschlüssel bleibt außerhalb des Servers. Verwaltungswerkzeug: `scripts/backup-recovery.cjs`; Einrichtung und Offline-Wiederherstellung stehen in der README.
|
||||||
|
- Noch nicht produktiv aktiviert oder veröffentlicht: Zuerst ein lokales Schlüsselpaar separat sichern, dann ausschließlich den öffentlichen Schlüssel über `RECOVERY_PUBLIC_KEY_FILE` im aktualisierten Sicherungsdienst konfigurieren. Der neue Client verweigert neue Exporte ohne verfügbaren öffentlichen Schlüssel. Alte Clients und bisherige Importe bleiben kompatibel. Keine Live-Datenänderung vorgenommen; Aktivierung benötigt das gesonderte Go nach Backup- und Staging-Prüfung.
|
||||||
|
|
||||||
- Aktive Arbeitslinie: `master` aus `Sucukdeluxe/Multi-Hoster-Upload`.
|
- Aktive Arbeitslinie: `master` aus `Sucukdeluxe/Multi-Hoster-Upload`.
|
||||||
- Zuletzt veröffentlichter Funktionsstand: Version `2.1.45`, Release-Commit `05769cd`.
|
- Zuletzt veröffentlichter Funktionsstand: Version `2.1.45`, Release-Commit `05769cd`.
|
||||||
- Version `2.1.45` ist auf GitHub und Forgejo veröffentlicht. Beide Release-Changelogs benennen die weiterhin fehlgeschlagene DoodStream-Web-Serverermittlung ausdrücklich als bekanntes Problem; ein erfolgreicher Dateiupload wird nicht behauptet.
|
- Version `2.1.45` ist auf GitHub und Forgejo veröffentlicht. Beide Release-Changelogs benennen die weiterhin fehlgeschlagene DoodStream-Web-Serverermittlung ausdrücklich als bekanntes Problem; ein erfolgreicher Dateiupload wird nicht behauptet.
|
||||||
@@ -66,10 +69,11 @@ npm audit --omit=dev
|
|||||||
|
|
||||||
## Zuletzt verifiziert
|
## Zuletzt verifiziert
|
||||||
|
|
||||||
Stand: 12.09.2026
|
Stand: 22.09.2026 (lokale Wiederherstellungserweiterung; Produktions- und Release-Prüfungen darunter weiterhin vom 12.09.2026)
|
||||||
|
|
||||||
- Lint: erfolgreich, 0 Warnungen und 0 Fehler.
|
- Lint: erfolgreich, 0 Warnungen und 0 Fehler.
|
||||||
- Haupttests: 818 erfolgreich, 0 fehlgeschlagen (Release-Prüfung für `2.1.45`).
|
- Haupttests: 822 erfolgreich, 0 fehlgeschlagen; einschließlich vier neuer Wiederherstellungstests und öffentlicher Quellmanifest-Prüfung (163 Dateien).
|
||||||
|
- Wiederherstellung lokal Ende-zu-Ende geprüft: öffentliche Schlüsselabfrage, verschlüsselter Serverdatensatz, normaler Import, private Offline-Wiederherstellung und Löschen. Falsche Schlüssel, beschädigte Daten, Kennungstausch, Ablauf, fehlende Serverkonfiguration und Überschreiben bestehender Schlüssel/Ausgabedateien werden geprüft. Anleitung zusätzlich auf dem lokalen Desktop abgelegt; keine produktiven privaten Schlüssel erzeugt.
|
||||||
- Backup-API-Tests: 17 erfolgreich, 0 fehlgeschlagen.
|
- Backup-API-Tests: 17 erfolgreich, 0 fehlgeschlagen.
|
||||||
- Der Regressionstest für die VOE-Fallback-Kette bestätigt bei deaktivierter normaler Rotation genau einen Versuch auf jedem vollen Account und anschließend den erfolgreichen Wechsel auf den vierten Account.
|
- Der Regressionstest für die VOE-Fallback-Kette bestätigt bei deaktivierter normaler Rotation genau einen Versuch auf jedem vollen Account und anschließend den erfolgreichen Wechsel auf den vierten Account.
|
||||||
- Der öffentliche DoodStream-Webablauf wurde am 12.09.2026 direkt gegen die Startseite und deren aktuelle Browser-Skripte geprüft. Regressionstests bilden den neuen GET-Login, `otp_sent`, `redirect`, Vue-Sessiontokens mit `_`/`-` und die aktuelle `upload_get_srv`-Antwort nach.
|
- Der öffentliche DoodStream-Webablauf wurde am 12.09.2026 direkt gegen die Startseite und deren aktuelle Browser-Skripte geprüft. Regressionstests bilden den neuen GET-Login, `otp_sent`, `redirect`, Vue-Sessiontokens mit `_`/`-` und die aktuelle `upload_get_srv`-Antwort nach.
|
||||||
|
|||||||
@@ -193,7 +193,15 @@ Both backup formats can contain credentials. Protect `.mhu` files like credentia
|
|||||||
|
|
||||||
### Encrypted online transfer
|
### Encrypted online transfer
|
||||||
|
|
||||||
Online backup is optional and transfers accounts and settings only. The application creates a 75-character key, encrypts the backup on the client with AES-256-GCM, and uploads only the encrypted blob. The service does not receive the decryption key. Queue state and history are not included.
|
Online backup is optional and transfers accounts and settings only. The application creates a 75-character key and encrypts the backup on the client with AES-256-GCM. New clients also encrypt the transfer key with the service operator's RSA-3072 public recovery key (OAEP-SHA256, bound to the record ID). The service stores only ciphertext and cannot decrypt backups by itself. Whoever holds the separate private recovery key and a stored record can recover its transfer key and contents. Queue state and history are not included.
|
||||||
|
|
||||||
|
#### Operator recovery setup
|
||||||
|
|
||||||
|
Before deploying the new client, run `node scripts/backup-recovery.cjs init <new-local-directory>` on a trusted local computer. Save `recovery-private.pem` separately in a password manager or encrypted offline storage. Do not put it on the backup server, in Git, or in support bundles. On Windows, use a private user directory with restricted inherited access permissions; POSIX file modes alone do not set Windows ACLs. Transfer only `recovery-public.pem` to the server and configure `RECOVERY_PUBLIC_KEY_FILE` with its absolute path. Deploy the updated backup service before the client. The client refuses new exports when the public key endpoint is unavailable or invalid; existing imports remain available. Older clients remain compatible but do not create recovery envelopes.
|
||||||
|
|
||||||
|
To recover a lost transfer key, obtain a read-only copy of the corresponding `<record-id>.json` file from the service's `BACKUP_DATA_DIR`, preserving the filename. Use SSH/admin access; there is deliberately no public backup-listing endpoint. If the ID was lost too, inspect record filenames, creation dates and expiry dates through that admin access. On the trusted local computer run `node scripts/backup-recovery.cjs recover <record-id>.json <recovery-private.pem> <new-output.txt>`. The tool verifies the ID, expiry, key fingerprint and encrypted payload, then writes the original transfer key to a new file without printing it. Import that key normally in the uploader. Protect and remove the temporary key file after use. Existing output files are never overwritten.
|
||||||
|
|
||||||
|
Recovery applies only to new records carrying recovery data. Old backups cannot be retroactively recovered without their original key. Expired or deleted backups are not recoverable through this workflow. Retain older private keys when rotating the public key; new keys cannot recover records encrypted for a previous key. No live data migration is required.
|
||||||
|
|
||||||
Treat the generated key like a password: anyone with it can restore the encrypted settings. Creating a new key does not invalidate older keys.
|
Treat the generated key like a password: anyone with it can restore the encrypted settings. Creating a new key does not invalidate older keys.
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function createOnlineBackupManager({
|
|||||||
createBackup = createOnlineBackup,
|
createBackup = createOnlineBackup,
|
||||||
uploadBackup = uploadOnlineBackup,
|
uploadBackup = uploadOnlineBackup,
|
||||||
deleteBackup = deleteOnlineBackup,
|
deleteBackup = deleteOnlineBackup,
|
||||||
|
loadRecoveryPublicKey = async () => null,
|
||||||
copyText
|
copyText
|
||||||
}) {
|
}) {
|
||||||
let mutation = Promise.resolve();
|
let mutation = Promise.resolve();
|
||||||
@@ -76,7 +77,8 @@ function createOnlineBackupManager({
|
|||||||
async function createTransaction(retention) {
|
async function createTransaction(retention) {
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
const created = createBackup(settings, appVersion(), createdAt, retention);
|
const recoveryPublicKey = await loadRecoveryPublicKey();
|
||||||
|
const created = createBackup(settings, appVersion(), createdAt, retention, recoveryPublicKey);
|
||||||
const prepared = keyring.prepare(created.key, createdAt, created.expiresAt ?? null);
|
const prepared = keyring.prepare(created.key, createdAt, created.expiresAt ?? null);
|
||||||
await uploadBackup(created.record);
|
await uploadBackup(created.record);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
|
function recoveryPublicKey(pem) {
|
||||||
|
if (typeof pem !== 'string' || pem.length > 8192 || !pem.startsWith('-----BEGIN PUBLIC KEY-----')) throw new Error('Ungültiger öffentlicher Wiederherstellungsschlüssel');
|
||||||
|
const key = crypto.createPublicKey(pem);
|
||||||
|
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength !== 3072) throw new Error('Wiederherstellung benötigt RSA-3072');
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprint(key) {
|
||||||
|
return crypto.createHash('sha256').update(key.export({ type: 'spki', format: 'der' })).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapBackupKey(key, id, pem) {
|
||||||
|
const publicKey = recoveryPublicKey(pem);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
keyId: fingerprint(publicKey),
|
||||||
|
wrappedKey: crypto.publicEncrypt({ key: publicKey, oaepHash: 'sha256', padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepLabel: Buffer.from(`MHU-RECOVERY-V1:${id}`) }, Buffer.from(key)).toString('base64url')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverBackupKey(id, record, privatePem) {
|
||||||
|
const { parseOnlineBackupKey, restoreOnlineBackup } = require('./online-backup');
|
||||||
|
if (!record?.recovery || record.recovery.version !== 1) throw new Error('Diese Sicherung enthält keine Wiederherstellungsdaten');
|
||||||
|
if (record.expiresAt !== null && (!record.expiresAt || !Number.isFinite(Date.parse(record.expiresAt)) || Date.parse(record.expiresAt) <= Date.now())) throw new Error('Die Sicherung ist abgelaufen oder ihr Ablaufdatum ist ungültig');
|
||||||
|
const privateKey = crypto.createPrivateKey(privatePem);
|
||||||
|
const publicKey = crypto.createPublicKey(privateKey);
|
||||||
|
if (record.recovery.keyId !== fingerprint(publicKey)) throw new Error('Der Wiederherstellungsschlüssel gehört nicht zu dieser Sicherung');
|
||||||
|
const wrapped = record.recovery.wrappedKey;
|
||||||
|
if (typeof wrapped !== 'string' || !/^[A-Za-z0-9_-]{512}$/.test(wrapped)) throw new Error('Ungültige Wiederherstellungsdaten');
|
||||||
|
const key = crypto.privateDecrypt({ key: privateKey, oaepHash: 'sha256', padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepLabel: Buffer.from(`MHU-RECOVERY-V1:${id}`) }, Buffer.from(wrapped, 'base64url')).toString('utf8');
|
||||||
|
if (parseOnlineBackupKey(key).id !== id) throw new Error('Die Sicherungskennung stimmt nicht überein');
|
||||||
|
restoreOnlineBackup(key, record.blob);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { recoveryPublicKey, wrapBackupKey, recoverBackupKey };
|
||||||
+12
-2
@@ -156,7 +156,7 @@ function onlineBackupExpiration(exportedAt, retention) {
|
|||||||
return new Date(created.getTime() + seconds * 1000).toISOString();
|
return new Date(created.getTime() + seconds * 1000).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString(), retention = DEFAULT_ONLINE_BACKUP_RETENTION) {
|
function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString(), retention = DEFAULT_ONLINE_BACKUP_RETENTION, recoveryPublicKey = null) {
|
||||||
const normalizedRetention = normalizeOnlineBackupRetention(retention);
|
const normalizedRetention = normalizeOnlineBackupRetention(retention);
|
||||||
const expiresInSeconds = ONLINE_BACKUP_RETENTION_SECONDS[normalizedRetention];
|
const expiresInSeconds = ONLINE_BACKUP_RETENTION_SECONDS[normalizedRetention];
|
||||||
const expiresAt = onlineBackupExpiration(exportedAt, normalizedRetention);
|
const expiresAt = onlineBackupExpiration(exportedAt, normalizedRetention);
|
||||||
@@ -192,7 +192,8 @@ function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOS
|
|||||||
id: parsed.id,
|
id: parsed.id,
|
||||||
blob: blobBytes.toString('base64url'),
|
blob: blobBytes.toString('base64url'),
|
||||||
deleteVerifier,
|
deleteVerifier,
|
||||||
expiresInSeconds
|
expiresInSeconds,
|
||||||
|
...(recoveryPublicKey ? { recovery: require('./online-backup-recovery').wrapBackupKey(key, parsed.id, recoveryPublicKey) } : {})
|
||||||
},
|
},
|
||||||
expiresAt
|
expiresAt
|
||||||
};
|
};
|
||||||
@@ -228,6 +229,14 @@ function restoreOnlineBackup(key, blob) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadRecoveryPublicKey(baseUrl = ONLINE_BACKUP_API_URL, options) {
|
||||||
|
const { response, body } = await requestText(endpoint(baseUrl, '/v1/recovery-key'), { method: 'GET', headers: { accept: 'application/json' } }, options);
|
||||||
|
if (response.status !== 200) throw new Error('Wiederherstellungsschlüssel des Sicherungsdienstes ist nicht verfügbar');
|
||||||
|
const value = JSON.parse(body);
|
||||||
|
require('./online-backup-recovery').recoveryPublicKey(value.publicKey);
|
||||||
|
return value.publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadOnlineBackup(record, baseUrl = ONLINE_BACKUP_API_URL, options) {
|
async function uploadOnlineBackup(record, baseUrl = ONLINE_BACKUP_API_URL, options) {
|
||||||
const { response } = await requestText(endpoint(baseUrl, '/v1/backups'), {
|
const { response } = await requestText(endpoint(baseUrl, '/v1/backups'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -277,6 +286,7 @@ module.exports = {
|
|||||||
createOnlineBackup,
|
createOnlineBackup,
|
||||||
deleteOnlineBackup,
|
deleteOnlineBackup,
|
||||||
downloadOnlineBackup,
|
downloadOnlineBackup,
|
||||||
|
downloadRecoveryPublicKey,
|
||||||
normalizeOnlineBackupRetention,
|
normalizeOnlineBackupRetention,
|
||||||
onlineBackupExpiration,
|
onlineBackupExpiration,
|
||||||
parseOnlineBackupKey,
|
parseOnlineBackupKey,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const { createAccountCooldownController, createAccountPicker } = require('./lib/
|
|||||||
const ClouddropUploader = require('./lib/clouddrop-upload');
|
const ClouddropUploader = require('./lib/clouddrop-upload');
|
||||||
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater');
|
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater');
|
||||||
const backupCrypto = require('./lib/backup-crypto');
|
const backupCrypto = require('./lib/backup-crypto');
|
||||||
const { downloadOnlineBackup, normalizeOnlineBackupRetention } = require('./lib/online-backup');
|
const { downloadOnlineBackup, downloadRecoveryPublicKey, normalizeOnlineBackupRetention } = require('./lib/online-backup');
|
||||||
const { createOnlineBackupKeyring } = require('./lib/online-backup-keyring');
|
const { createOnlineBackupKeyring } = require('./lib/online-backup-keyring');
|
||||||
const { createOnlineBackupManager } = require('./lib/online-backup-manager');
|
const { createOnlineBackupManager } = require('./lib/online-backup-manager');
|
||||||
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
||||||
@@ -132,6 +132,7 @@ const onlineBackupKeyring = createOnlineBackupKeyring({
|
|||||||
filePath: path.join(app.getPath('userData'), 'online-backup-keys.json')
|
filePath: path.join(app.getPath('userData'), 'online-backup-keys.json')
|
||||||
});
|
});
|
||||||
const onlineBackupManager = createOnlineBackupManager({
|
const onlineBackupManager = createOnlineBackupManager({
|
||||||
|
loadRecoveryPublicKey: downloadRecoveryPublicKey,
|
||||||
keyring: onlineBackupKeyring,
|
keyring: onlineBackupKeyring,
|
||||||
loadSettings: async () => {
|
loadSettings: async () => {
|
||||||
await waitForConfigStoreWrites();
|
await waitForConfigStoreWrites();
|
||||||
|
|||||||
@@ -643,6 +643,14 @@
|
|||||||
['Einstellungen werden bereits importiert', 'Settings are already being imported'],
|
['Einstellungen werden bereits importiert', 'Settings are already being imported'],
|
||||||
['Während laufender Uploads können keine Einstellungen importiert werden', 'Settings cannot be imported while uploads are running'],
|
['Während laufender Uploads können keine Einstellungen importiert werden', 'Settings cannot be imported while uploads are running'],
|
||||||
['Online-Sicherungsschlüssel ist ungültig', 'The online backup key is invalid'],
|
['Online-Sicherungsschlüssel ist ungültig', 'The online backup key is invalid'],
|
||||||
|
['Ungültiger öffentlicher Wiederherstellungsschlüssel', 'Invalid public recovery key'],
|
||||||
|
['Wiederherstellung benötigt RSA-3072', 'Recovery requires RSA-3072'],
|
||||||
|
['Diese Sicherung enthält keine Wiederherstellungsdaten', 'This backup contains no recovery data'],
|
||||||
|
['Die Sicherung ist abgelaufen oder ihr Ablaufdatum ist ungültig', 'The backup has expired or its expiry date is invalid'],
|
||||||
|
['Der Wiederherstellungsschlüssel gehört nicht zu dieser Sicherung', 'The recovery key does not match this backup'],
|
||||||
|
['Ungültige Wiederherstellungsdaten', 'Invalid recovery data'],
|
||||||
|
['Die Sicherungskennung stimmt nicht überein', 'The backup identifier does not match'],
|
||||||
|
['Wiederherstellungsschlüssel des Sicherungsdienstes ist nicht verfügbar', 'The backup service recovery key is unavailable'],
|
||||||
['Ein Update wird bereits vorbereitet', 'An update is already being prepared'],
|
['Ein Update wird bereits vorbereitet', 'An update is already being prepared'],
|
||||||
['Die Anwendung ist noch nicht bereit, das Update sicher zu installieren', 'The application is not yet ready to install the update safely'],
|
['Die Anwendung ist noch nicht bereit, das Update sicher zu installieren', 'The application is not yet ready to install the update safely'],
|
||||||
['Update abgebrochen', 'Update canceled'],
|
['Update abgebrochen', 'Update canceled'],
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const { generateKeyPairSync } = require('node:crypto');
|
||||||
|
const { mkdir, readFile, writeFile } = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { recoverBackupKey } = require('../lib/online-backup-recovery');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [command, ...args] = process.argv.slice(2);
|
||||||
|
if (command === 'init' && args.length === 1) {
|
||||||
|
const directory = path.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 writeFile(path.join(directory, 'recovery-private.pem'), pair.privateKey, { flag: 'wx', mode: 0o600 });
|
||||||
|
await writeFile(path.join(directory, 'recovery-public.pem'), pair.publicKey, { flag: 'wx', mode: 0o600 });
|
||||||
|
process.stdout.write('Schlüsselpaar erstellt. Private Datei separat sichern; nur die öffentliche Datei auf den Server übertragen.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === 'recover' && args.length === 3) {
|
||||||
|
const [recordFile, privateFile, outputFile] = args.map(value => path.resolve(value));
|
||||||
|
const id = path.basename(recordFile, '.json');
|
||||||
|
if (!/^[A-Za-z0-9_-]{22}$/.test(id)) throw new Error('Der Datensatz muss seinen ursprünglichen Dateinamen behalten');
|
||||||
|
const record = JSON.parse(await readFile(recordFile, 'utf8'));
|
||||||
|
const key = recoverBackupKey(id, record, await readFile(privateFile, 'utf8'));
|
||||||
|
await writeFile(outputFile, `${key}\n`, { flag: 'wx', mode: 0o600 });
|
||||||
|
process.stdout.write('Online-Schlüssel in der angegebenen Ausgabedatei gespeichert.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('Aufruf: node scripts/backup-recovery.cjs init <neuer-Ordner> | recover <Datensatz.json> <private.pem> <neue-Ausgabedatei>');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(() => {
|
||||||
|
process.stderr.write('Wiederherstellung fehlgeschlagen. Aufruf, Dateipfade, Ablaufdatum und Schlüsselzuordnung prüfen. Vorhandene Dateien werden nicht überschrieben.\n');
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -40,6 +40,7 @@ const sourceFiles = [
|
|||||||
'lib/online-backup.js',
|
'lib/online-backup.js',
|
||||||
'lib/online-backup-keyring.js',
|
'lib/online-backup-keyring.js',
|
||||||
'lib/online-backup-manager.js',
|
'lib/online-backup-manager.js',
|
||||||
|
'lib/online-backup-recovery.js',
|
||||||
'lib/orphan-tmp.js',
|
'lib/orphan-tmp.js',
|
||||||
'lib/queue-dedup.js',
|
'lib/queue-dedup.js',
|
||||||
'lib/queue-prune.js',
|
'lib/queue-prune.js',
|
||||||
@@ -87,6 +88,7 @@ const sourceFiles = [
|
|||||||
'renderer/queue-stats.js',
|
'renderer/queue-stats.js',
|
||||||
'renderer/styles.css',
|
'renderer/styles.css',
|
||||||
'scripts/afterPack.cjs',
|
'scripts/afterPack.cjs',
|
||||||
|
'scripts/backup-recovery.cjs',
|
||||||
'scripts/dev-runner.cjs',
|
'scripts/dev-runner.cjs',
|
||||||
'scripts/release-plan.mjs',
|
'scripts/release-plan.mjs',
|
||||||
'scripts/verify-public-release.mjs',
|
'scripts/verify-public-release.mjs',
|
||||||
@@ -95,6 +97,7 @@ const sourceFiles = [
|
|||||||
'services/backup-api/src/cli.mjs',
|
'services/backup-api/src/cli.mjs',
|
||||||
'services/backup-api/src/server.mjs',
|
'services/backup-api/src/server.mjs',
|
||||||
'services/backup-api/test/server.test.mjs',
|
'services/backup-api/test/server.test.mjs',
|
||||||
|
'tests/online-backup-recovery.test.js',
|
||||||
'tests/account-auth.test.js',
|
'tests/account-auth.test.js',
|
||||||
'tests/account-rotation.test.js',
|
'tests/account-rotation.test.js',
|
||||||
'tests/account-status.test.js',
|
'tests/account-status.test.js',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
import { createBackupServer } from './server.mjs'
|
import { createBackupServer } from './server.mjs'
|
||||||
|
|
||||||
const port = Number.parseInt(process.env.PORT ?? '8788', 10)
|
const port = Number.parseInt(process.env.PORT ?? '8788', 10)
|
||||||
@@ -35,6 +36,7 @@ const trustedProxyAddresses = (process.env.TRUSTED_PROXY_ADDRESSES ?? '127.0.0.1
|
|||||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT')
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT')
|
||||||
|
|
||||||
const server = createBackupServer({
|
const server = createBackupServer({
|
||||||
|
recoveryPublicKey: process.env.RECOVERY_PUBLIC_KEY_FILE ? await readFile(resolve(process.env.RECOVERY_PUBLIC_KEY_FILE), 'utf8') : undefined,
|
||||||
rootDir,
|
rootDir,
|
||||||
allowedOrigins,
|
allowedOrigins,
|
||||||
rateLimit,
|
rateLimit,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'
|
import { createHash, createPublicKey, timingSafeEqual, randomBytes } from 'node:crypto'
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
|
import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
|
||||||
import { isIP } from 'node:net'
|
import { isIP } from 'node:net'
|
||||||
@@ -21,7 +21,8 @@ function isCanonicalBase64Url(value, byteLength, pattern) {
|
|||||||
|
|
||||||
function isValidBackup(payload) {
|
function isValidBackup(payload) {
|
||||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
||||||
const keys = Object.keys(payload).sort()
|
const keys = Object.keys(payload).filter(key => key !== 'recovery').sort()
|
||||||
|
if (Object.hasOwn(payload, 'recovery') && !validRecovery(payload.recovery)) return false
|
||||||
const shape = keys.join(',')
|
const shape = keys.join(',')
|
||||||
if (shape !== 'blob,deleteVerifier,id' && shape !== 'blob,deleteVerifier,expiresInSeconds,id') return false
|
if (shape !== 'blob,deleteVerifier,id' && shape !== 'blob,deleteVerifier,expiresInSeconds,id') return false
|
||||||
if (shape.includes('expiresInSeconds') && payload.expiresInSeconds !== null && !allowedRetentionSeconds.has(payload.expiresInSeconds)) return false
|
if (shape.includes('expiresInSeconds') && payload.expiresInSeconds !== null && !allowedRetentionSeconds.has(payload.expiresInSeconds)) return false
|
||||||
@@ -167,6 +168,14 @@ async function directoryUsage(rootDir) {
|
|||||||
return { bytes, records }
|
return { bytes, records }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validRecovery(value) {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
&& Object.keys(value).sort().join(',') === 'keyId,version,wrappedKey'
|
||||||
|
&& value.version === 1
|
||||||
|
&& isCanonicalBase64Url(value.keyId, 32, verifierPattern)
|
||||||
|
&& isCanonicalBase64Url(value.wrappedKey, 384, blobPattern)
|
||||||
|
}
|
||||||
|
|
||||||
function isCanonicalTimestamp(value) {
|
function isCanonicalTimestamp(value) {
|
||||||
if (typeof value !== 'string') return false
|
if (typeof value !== 'string') return false
|
||||||
const timestamp = new Date(value)
|
const timestamp = new Date(value)
|
||||||
@@ -262,7 +271,8 @@ async function createRecord(rootDir, payload, maxStorageBytes, maxRecords, nowMs
|
|||||||
: null
|
: null
|
||||||
const expiresAt = expiresInSeconds === null ? null : new Date(nowMs + expiresInSeconds * 1000).toISOString()
|
const expiresAt = expiresInSeconds === null ? null : new Date(nowMs + expiresInSeconds * 1000).toISOString()
|
||||||
const contents = Buffer.from(JSON.stringify({
|
const contents = Buffer.from(JSON.stringify({
|
||||||
version: 2,
|
version: payload.recovery ? 3 : 2,
|
||||||
|
...(payload.recovery ? { recovery: payload.recovery } : {}),
|
||||||
blob: payload.blob,
|
blob: payload.blob,
|
||||||
deleteVerifier: payload.deleteVerifier,
|
deleteVerifier: payload.deleteVerifier,
|
||||||
createdAt: new Date(nowMs).toISOString(),
|
createdAt: new Date(nowMs).toISOString(),
|
||||||
@@ -331,8 +341,8 @@ async function readRecord(rootDir, id) {
|
|||||||
&& validBlob
|
&& validBlob
|
||||||
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
|
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
|
||||||
&& isCanonicalTimestamp(record.createdAt)
|
&& isCanonicalTimestamp(record.createdAt)
|
||||||
const expiring = keys === 'blob,createdAt,deleteVerifier,expiresAt,version'
|
const expiring = ((keys === 'blob,createdAt,deleteVerifier,expiresAt,version' && record.version === 2)
|
||||||
&& record.version === 2
|
|| (keys === 'blob,createdAt,deleteVerifier,expiresAt,recovery,version' && record.version === 3 && validRecovery(record.recovery)))
|
||||||
&& validBlob
|
&& validBlob
|
||||||
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
|
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
|
||||||
&& isCanonicalTimestamp(record.createdAt)
|
&& isCanonicalTimestamp(record.createdAt)
|
||||||
@@ -427,6 +437,15 @@ function clientAddress(request, trustedProxy, trustedProxyAddresses) {
|
|||||||
|
|
||||||
export function createBackupServer(options) {
|
export function createBackupServer(options) {
|
||||||
if (!options?.rootDir) throw new Error('rootDir is required')
|
if (!options?.rootDir) throw new Error('rootDir is required')
|
||||||
|
let recoveryKeyId = null
|
||||||
|
let recoveryPublicKey = null
|
||||||
|
if (options.recoveryPublicKey !== undefined) {
|
||||||
|
if (typeof options.recoveryPublicKey !== 'string' || !options.recoveryPublicKey.startsWith('-----BEGIN PUBLIC KEY-----')) throw new Error('Invalid recovery public key')
|
||||||
|
const key = createPublicKey(options.recoveryPublicKey)
|
||||||
|
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength !== 3072) throw new Error('Recovery requires RSA-3072')
|
||||||
|
recoveryPublicKey = key.export({ type: 'spki', format: 'pem' })
|
||||||
|
recoveryKeyId = createHash('sha256').update(key.export({ type: 'spki', format: 'der' })).digest('base64url')
|
||||||
|
}
|
||||||
const allowedOrigins = new Set(options.allowedOrigins ?? [])
|
const allowedOrigins = new Set(options.allowedOrigins ?? [])
|
||||||
const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
|
const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
|
||||||
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
|
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
|
||||||
@@ -490,6 +509,15 @@ export function createBackupServer(options) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const address = clientAddress(request, options.trustedProxy === true, trustedProxyAddresses)
|
const address = clientAddress(request, options.trustedProxy === true, trustedProxyAddresses)
|
||||||
|
if (request.method === 'GET' && url.pathname === '/v1/recovery-key') {
|
||||||
|
const retryAfter = consumeRequestRateLimit(address)
|
||||||
|
if (retryAfter !== null) {
|
||||||
|
response.setHeader('retry-after', String(retryAfter))
|
||||||
|
sendJson(response, 429, { error: 'rate_limited' })
|
||||||
|
} else if (recoveryPublicKey) sendJson(response, 200, { publicKey: recoveryPublicKey })
|
||||||
|
else sendJson(response, 503, { error: 'recovery_unavailable' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') {
|
if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') {
|
||||||
const retryAfter = consumeRateLimit(address)
|
const retryAfter = consumeRateLimit(address)
|
||||||
if (retryAfter !== null) {
|
if (retryAfter !== null) {
|
||||||
@@ -582,6 +610,10 @@ export function createBackupServer(options) {
|
|||||||
sendJson(response, 400, { error: 'invalid_request' })
|
sendJson(response, 400, { error: 'invalid_request' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (parsed.value.recovery && parsed.value.recovery.keyId !== recoveryKeyId) {
|
||||||
|
sendJson(response, 409, { error: 'recovery_key_changed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
const uploadRetryAfter = consumeUploadRateLimit(address)
|
const uploadRetryAfter = consumeUploadRateLimit(address)
|
||||||
if (uploadRetryAfter !== null) {
|
if (uploadRetryAfter !== null) {
|
||||||
response.setHeader('retry-after', String(uploadRetryAfter))
|
response.setHeader('retry-after', String(uploadRetryAfter))
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ describe('transactional online backup manager', () => {
|
|||||||
const result = await fixture.manager.createManaged();
|
const result = await fixture.manager.createManaged();
|
||||||
|
|
||||||
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit']);
|
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit']);
|
||||||
assert.deepEqual(fixture.createArguments, [settings, '2.1.31', fixture.createdAt, '7d']);
|
assert.deepEqual(fixture.createArguments, [settings, '2.1.31', fixture.createdAt, '7d', null]);
|
||||||
assert.match(fixture.createdAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
assert.match(fixture.createdAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||||
assert.deepEqual(result, {
|
assert.deepEqual(result, {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { generateKeyPairSync } = require('node:crypto');
|
||||||
|
const { mkdtemp, readFile, writeFile, rm } = require('node:fs/promises');
|
||||||
|
const { tmpdir } = require('node:os');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const { createOnlineBackup, downloadRecoveryPublicKey, uploadOnlineBackup, downloadOnlineBackup, deleteOnlineBackup } = require('../lib/online-backup');
|
||||||
|
const { recoverBackupKey, recoveryPublicKey } = require('../lib/online-backup-recovery');
|
||||||
|
const pair = generateKeyPairSync('rsa', { modulusLength: 3072, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
|
||||||
|
|
||||||
|
test('recovery binds the original key to its record and validates ciphertext and expiry', () => {
|
||||||
|
const backup = createOnlineBackup({ language: 'de' }, 'test', undefined, 'forever', pair.publicKey);
|
||||||
|
const record = { ...backup.record, expiresAt: null };
|
||||||
|
assert.equal(recoverBackupKey(record.id, record, pair.privateKey), backup.key);
|
||||||
|
assert.throws(() => recoverBackupKey(record.id, { ...record, expiresAt: '2020-01-01T00:00:00.000Z' }, pair.privateKey));
|
||||||
|
assert.throws(() => recoverBackupKey(record.id, { ...record, expiresAt: 'invalid' }, pair.privateKey));
|
||||||
|
assert.throws(() => recoverBackupKey(record.id, { ...record, recovery: undefined }, pair.privateKey));
|
||||||
|
assert.throws(() => recoverBackupKey('x'.repeat(22), record, pair.privateKey));
|
||||||
|
assert.throws(() => recoverBackupKey(record.id, { ...record, blob: 'AAAA' }, pair.privateKey));
|
||||||
|
assert.throws(() => recoverBackupKey(record.id, { ...record, recovery: { ...record.recovery, keyId: 'wrong' } }, pair.privateKey));
|
||||||
|
assert.throws(() => recoverBackupKey(record.id, { ...record, recovery: { ...record.recovery, wrappedKey: 'A'.repeat(512) } }, pair.privateKey));
|
||||||
|
assert.throws(() => recoveryPublicKey(pair.privateKey));
|
||||||
|
const weak = generateKeyPairSync('rsa', { modulusLength: 2048 }).publicKey.export({ type: 'spki', format: 'pem' });
|
||||||
|
assert.throws(() => recoveryPublicKey(weak));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('server persists recovery without plaintext secrets, supports normal import and offline recovery', async t => {
|
||||||
|
const { createBackupServer } = await import('../services/backup-api/src/server.mjs');
|
||||||
|
const rootDir = await mkdtemp(join(tmpdir(), 'mhu-recovery-'));
|
||||||
|
let now = Date.now();
|
||||||
|
const server = createBackupServer({ rootDir, recoveryPublicKey: pair.publicKey, now: () => now });
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
t.after(async () => { await new Promise(resolve => server.close(resolve)); await rm(rootDir, { recursive: true, force: true }); });
|
||||||
|
const url = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
const publicKey = await downloadRecoveryPublicKey(url);
|
||||||
|
assert.equal(publicKey, pair.publicKey);
|
||||||
|
const backup = createOnlineBackup({ language: 'de', password: 'test-secret' }, 'test', undefined, '1d', publicKey);
|
||||||
|
await uploadOnlineBackup(backup.record, url);
|
||||||
|
const recordPath = join(rootDir, `${backup.record.id}.json`);
|
||||||
|
const raw = await readFile(recordPath, 'utf8');
|
||||||
|
assert.ok(!raw.includes(backup.key));
|
||||||
|
assert.ok(!raw.includes('test-secret'));
|
||||||
|
const record = JSON.parse(raw);
|
||||||
|
assert.equal(record.version, 3);
|
||||||
|
assert.equal(recoverBackupKey(backup.record.id, record, pair.privateKey), backup.key);
|
||||||
|
assert.equal((await downloadOnlineBackup(backup.key, url)).settings.language, 'de');
|
||||||
|
const privateFile = join(rootDir, 'private.pem');
|
||||||
|
const output = join(rootDir, 'result.txt');
|
||||||
|
await writeFile(privateFile, pair.privateKey);
|
||||||
|
const cli = join(__dirname, '../scripts/backup-recovery.cjs');
|
||||||
|
const result = spawnSync(process.execPath, [cli, 'recover', recordPath, privateFile, output], { encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal((await readFile(output, 'utf8')).trim(), backup.key);
|
||||||
|
assert.ok(!result.stdout.includes(backup.key));
|
||||||
|
assert.equal(spawnSync(process.execPath, [cli, 'recover', recordPath, privateFile, output]).status, 1);
|
||||||
|
const other = createOnlineBackup({}, 'test', undefined, 'forever', publicKey);
|
||||||
|
await uploadOnlineBackup(other.record, url);
|
||||||
|
assert.equal((await deleteOnlineBackup(other.key, url)).deleted, true);
|
||||||
|
await assert.rejects(uploadOnlineBackup({ ...other.record, recovery: { ...other.record.recovery, wrappedKey: 'bad' } }, url));
|
||||||
|
await assert.rejects(uploadOnlineBackup({ ...other.record, recovery: { ...other.record.recovery, keyId: Buffer.alloc(32).toString('base64url') } }, url));
|
||||||
|
now += 86400 * 1000;
|
||||||
|
await assert.rejects(downloadOnlineBackup(backup.key, url));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing or invalid public configuration fails closed without uploading', async () => {
|
||||||
|
await assert.rejects(downloadRecoveryPublicKey('https://example.test', { fetchImpl: async () => new Response('{}', { status: 503 }) }));
|
||||||
|
await assert.rejects(downloadRecoveryPublicKey('https://example.test', { fetchImpl: async () => new Response('{"publicKey":"bad"}') }));
|
||||||
|
const { createOnlineBackupManager } = require('../lib/online-backup-manager');
|
||||||
|
let uploaded = false;
|
||||||
|
const manager = createOnlineBackupManager({ loadSettings: async () => ({}), loadRecoveryPublicKey: async () => { throw new Error('unavailable'); }, uploadBackup: async () => { uploaded = true; } });
|
||||||
|
assert.equal((await manager.createManaged()).ok, false);
|
||||||
|
assert.equal(uploaded, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline setup creates a matching key pair and never replaces existing keys', async t => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mhu-recovery-setup-'));
|
||||||
|
t.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const directory = join(root, 'keys');
|
||||||
|
const cli = join(__dirname, '../scripts/backup-recovery.cjs');
|
||||||
|
const result = spawnSync(process.execPath, [cli, 'init', directory], { encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
const publicPem = await readFile(join(directory, 'recovery-public.pem'), 'utf8');
|
||||||
|
const privatePem = await readFile(join(directory, 'recovery-private.pem'), 'utf8');
|
||||||
|
const backup = createOnlineBackup({}, 'test', undefined, 'forever', publicPem);
|
||||||
|
assert.equal(recoverBackupKey(backup.record.id, { ...backup.record, expiresAt: null }, privatePem), backup.key);
|
||||||
|
assert.equal(spawnSync(process.execPath, [cli, 'init', directory]).status, 1);
|
||||||
|
assert.equal(await readFile(join(directory, 'recovery-private.pem'), 'utf8'), privatePem);
|
||||||
|
assert.ok(!result.stdout.includes('PRIVATE KEY'));
|
||||||
|
});
|
||||||
@@ -45,7 +45,7 @@ test('public release verifier accepts only the exact source manifest and target
|
|||||||
assert.equal(baseline.status, 0, baseline.stderr);
|
assert.equal(baseline.status, 0, baseline.stderr);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
baseline.stdout,
|
baseline.stdout,
|
||||||
`public-release-source-ok files=160 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n`
|
`public-release-source-ok files=163 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
|
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
|
||||||
|
|||||||
Reference in New Issue
Block a user