Add encrypted operator recovery for online backup keys
CI / verify (push) Waiting to run

This commit is contained in:
Sucukdeluxe
2026-09-22 04:34:52 +02:00
parent fa2ca27424
commit de1b79ed4b
15 changed files with 251 additions and 14 deletions
+2
View File
@@ -22,3 +22,5 @@ account-rotation.log
doodstream-debug.log
upload-debug.log
release-*.log
recovery-private.pem
recovered-online-key*.txt
+6 -2
View File
@@ -6,6 +6,9 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große
## 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`.
- 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.
@@ -66,10 +69,11 @@ npm audit --omit=dev
## 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.
- 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.
- 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.
+9 -1
View File
@@ -193,7 +193,15 @@ Both backup formats can contain credentials. Protect `.mhu` files like credentia
### 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.
+3 -1
View File
@@ -54,6 +54,7 @@ function createOnlineBackupManager({
createBackup = createOnlineBackup,
uploadBackup = uploadOnlineBackup,
deleteBackup = deleteOnlineBackup,
loadRecoveryPublicKey = async () => null,
copyText
}) {
let mutation = Promise.resolve();
@@ -76,7 +77,8 @@ function createOnlineBackupManager({
async function createTransaction(retention) {
const createdAt = new Date().toISOString();
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);
await uploadBackup(created.record);
try {
+38
View File
@@ -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
View File
@@ -156,7 +156,7 @@ function onlineBackupExpiration(exportedAt, retention) {
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 expiresInSeconds = ONLINE_BACKUP_RETENTION_SECONDS[normalizedRetention];
const expiresAt = onlineBackupExpiration(exportedAt, normalizedRetention);
@@ -192,7 +192,8 @@ function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOS
id: parsed.id,
blob: blobBytes.toString('base64url'),
deleteVerifier,
expiresInSeconds
expiresInSeconds,
...(recoveryPublicKey ? { recovery: require('./online-backup-recovery').wrapBackupKey(key, parsed.id, recoveryPublicKey) } : {})
},
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) {
const { response } = await requestText(endpoint(baseUrl, '/v1/backups'), {
method: 'POST',
@@ -277,6 +286,7 @@ module.exports = {
createOnlineBackup,
deleteOnlineBackup,
downloadOnlineBackup,
downloadRecoveryPublicKey,
normalizeOnlineBackupRetention,
onlineBackupExpiration,
parseOnlineBackupKey,
+2 -1
View File
@@ -19,7 +19,7 @@ const { createAccountCooldownController, createAccountPicker } = require('./lib/
const ClouddropUploader = require('./lib/clouddrop-upload');
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater');
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 { createOnlineBackupManager } = require('./lib/online-backup-manager');
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
@@ -132,6 +132,7 @@ const onlineBackupKeyring = createOnlineBackupKeyring({
filePath: path.join(app.getPath('userData'), 'online-backup-keys.json')
});
const onlineBackupManager = createOnlineBackupManager({
loadRecoveryPublicKey: downloadRecoveryPublicKey,
keyring: onlineBackupKeyring,
loadSettings: async () => {
await waitForConfigStoreWrites();
+8
View File
@@ -643,6 +643,14 @@
['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'],
['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'],
['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'],
+37
View File
@@ -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;
});
+3
View File
@@ -40,6 +40,7 @@ const sourceFiles = [
'lib/online-backup.js',
'lib/online-backup-keyring.js',
'lib/online-backup-manager.js',
'lib/online-backup-recovery.js',
'lib/orphan-tmp.js',
'lib/queue-dedup.js',
'lib/queue-prune.js',
@@ -87,6 +88,7 @@ const sourceFiles = [
'renderer/queue-stats.js',
'renderer/styles.css',
'scripts/afterPack.cjs',
'scripts/backup-recovery.cjs',
'scripts/dev-runner.cjs',
'scripts/release-plan.mjs',
'scripts/verify-public-release.mjs',
@@ -95,6 +97,7 @@ const sourceFiles = [
'services/backup-api/src/cli.mjs',
'services/backup-api/src/server.mjs',
'services/backup-api/test/server.test.mjs',
'tests/online-backup-recovery.test.js',
'tests/account-auth.test.js',
'tests/account-rotation.test.js',
'tests/account-status.test.js',
+2
View File
@@ -1,4 +1,5 @@
import { resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
import { createBackupServer } from './server.mjs'
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')
const server = createBackupServer({
recoveryPublicKey: process.env.RECOVERY_PUBLIC_KEY_FILE ? await readFile(resolve(process.env.RECOVERY_PUBLIC_KEY_FILE), 'utf8') : undefined,
rootDir,
allowedOrigins,
rateLimit,
+37 -5
View File
@@ -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 { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
import { isIP } from 'node:net'
@@ -21,7 +21,8 @@ function isCanonicalBase64Url(value, byteLength, pattern) {
function isValidBackup(payload) {
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(',')
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
@@ -167,6 +168,14 @@ async function directoryUsage(rootDir) {
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) {
if (typeof value !== 'string') return false
const timestamp = new Date(value)
@@ -262,7 +271,8 @@ async function createRecord(rootDir, payload, maxStorageBytes, maxRecords, nowMs
: null
const expiresAt = expiresInSeconds === null ? null : new Date(nowMs + expiresInSeconds * 1000).toISOString()
const contents = Buffer.from(JSON.stringify({
version: 2,
version: payload.recovery ? 3 : 2,
...(payload.recovery ? { recovery: payload.recovery } : {}),
blob: payload.blob,
deleteVerifier: payload.deleteVerifier,
createdAt: new Date(nowMs).toISOString(),
@@ -331,8 +341,8 @@ async function readRecord(rootDir, id) {
&& validBlob
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
&& isCanonicalTimestamp(record.createdAt)
const expiring = keys === 'blob,createdAt,deleteVerifier,expiresAt,version'
&& record.version === 2
const expiring = ((keys === 'blob,createdAt,deleteVerifier,expiresAt,version' && record.version === 2)
|| (keys === 'blob,createdAt,deleteVerifier,expiresAt,recovery,version' && record.version === 3 && validRecovery(record.recovery)))
&& validBlob
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
&& isCanonicalTimestamp(record.createdAt)
@@ -427,6 +437,15 @@ function clientAddress(request, trustedProxy, trustedProxyAddresses) {
export function createBackupServer(options) {
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 rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
@@ -490,6 +509,15 @@ export function createBackupServer(options) {
return
}
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') {
const retryAfter = consumeRateLimit(address)
if (retryAfter !== null) {
@@ -582,6 +610,10 @@ export function createBackupServer(options) {
sendJson(response, 400, { error: 'invalid_request' })
return
}
if (parsed.value.recovery && parsed.value.recovery.keyId !== recoveryKeyId) {
sendJson(response, 409, { error: 'recovery_key_changed' })
return
}
const uploadRetryAfter = consumeUploadRateLimit(address)
if (uploadRetryAfter !== null) {
response.setHeader('retry-after', String(uploadRetryAfter))
+1 -1
View File
@@ -182,7 +182,7 @@ describe('transactional online backup manager', () => {
const result = await fixture.manager.createManaged();
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.deepEqual(result, {
ok: true,
+90
View File
@@ -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'));
});
+1 -1
View File
@@ -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.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'), '{}');