From 43b77401c6cf20048dd4f611336380b9eb545b25 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:02:21 +0200 Subject: [PATCH] Track online backup source IP and add detailed recovery output --- PROJECT_MEMORY.md | 6 ++++- README.md | 4 +++ lib/online-backup-keyring.js | 14 ++++++++--- lib/online-backup-manager.js | 11 +++++--- lib/online-backup.js | 8 +++++- renderer/app.js | 7 +++--- renderer/i18n.js | 1 + renderer/styles.css | 1 + scripts/backup-recovery.cjs | 17 ++++++++++--- services/backup-api/src/server.mjs | 20 +++++++++++---- services/backup-api/test/server.test.mjs | 32 +++++++++++++++++++++++- tests/i18n.test.js | 8 ++++++ tests/online-backup-keyring.test.js | 14 +++++++++++ tests/online-backup-manager.test.js | 13 +++++++++- tests/online-backup-recovery.test.js | 17 +++++++++++-- 15 files changed, 147 insertions(+), 26 deletions(-) diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index d0f89c8..547fbde 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -6,6 +6,10 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große ## Aktueller Zustand +- IP-Erweiterung nach `2.1.47` lokal umgesetzt, noch nicht aktiviert oder veröffentlicht: Der Dienst speichert die serverseitig ermittelte Herkunfts-IP neuer Sicherungen und gibt sie ausschließlich in der Erstellungsantwort zurück. Client-Schlüsselbund und Oberfläche behalten die IP neben Datum/Uhrzeit; alte Einträge zeigen `IP unbekannt`. Offline-Wiederherstellung unterstützt `--details` für `Datum - Uhrzeit | IP: Adresse | MHU2-…` (Zeitzone Europe/Berlin), ohne Flag weiterhin nur den Schlüssel. Private Schlüssel werden nicht übertragen und vollständige Online-Schlüssel nicht in der Übersicht angezeigt. +- IP-Datensätze verwenden Version 4; neuer Dienst liest weiterhin Versionen 1–3. Ein Rückwechsel auf die alte Serverversion nach ersten Version-4-Datensätzen erfordert einen datenerhaltenden Kompatibilitätsplan. Noch keine Live-Datenänderung für diese Erweiterung. Vor Aktivierung erneut Backup und Staging prüfen und unmittelbar vor Live-Umstellung ausdrückliches Go einholen. Bestehende IPs nicht nachträglich raten. +- Read-only geprüft: Der produktive Proxy für den Backup-Endpunkt überschreibt `X-Forwarded-For` mit der beobachteten Verbindungsadresse; der Dienst vertraut ausschließlich den konfigurierten Loopback-Proxyadressen. Die IP kann die öffentliche Adresse eines VPN/NAT darstellen und ist keine eindeutige Gerätekennung. Sie unterliegt derselben Aufbewahrung wie der Backupdatensatz und wird nicht vom öffentlichen Restore-Endpunkt ausgegeben. + - Version `2.1.47` veröffentlicht: Standardgültigkeit neuer Online-Schlüssel ist `Unbegrenzt`. Bestehende Ablaufdaten bleiben unverändert; Backup-API bleibt `2.0.5`. Release-Commit `afdad99`, annotierter Tag auf beiden Plattformen identisch. 823 Haupttests, 17 Servertests, Lint und Audit erfolgreich. Installer 98.032.502 Bytes, Portable 97.807.793 Bytes. Alle acht veröffentlichten Dateien erneut heruntergeladen und per SHA-512 geprüft. Titel, Sprachen, Assets und Update-Manifeste auf beiden Plattformen verifiziert. Der eingebaute Updater erkennt `2.1.47` ausgehend von `2.1.46`. - 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. @@ -79,7 +83,7 @@ Stand: 22.09.2026 (Produktivaktivierung und Release `2.1.46`; ältere Versionspr - Eingebauter Updater mit isoliertem Testprofil und installierter Version `2.1.45` erkennt `2.1.46`, übernimmt den englischen GitHub-Changelog und akzeptiert das Forgejo-Update-Manifest. Alle acht veröffentlichten Dateien wurden erneut heruntergeladen und anhand Größe und SHA-512 gegen die lokalen Dateien geprüft. Titel, Tag, Sprache, Veröffentlichungsstatus und beide anbieterspezifischen Manifeste stimmen. Vollständiger öffentlicher Quellcheck: 163 erlaubte Dateien, Screenshots gültig. Anleitung auf dem Desktop mit abgeschlossenem Live- und Release-Stand aktualisiert. - Lint: erfolgreich, 0 Warnungen und 0 Fehler. -- Haupttests: 823 erfolgreich, 0 fehlgeschlagen; einschließlich Standardgültigkeit `Unbegrenzt`, deutscher/englischer Auswahl, aller expliziten Laufzeiten, vier Wiederherstellungstests und öffentlicher Quellmanifest-Prüfung (163 Dateien). Backup-API weiterhin 17 Tests erfolgreich; Lint ohne Fehler. Die Änderung der Standardgültigkeit ist mit `2.1.47` veröffentlicht. +- Haupttests: 826 erfolgreich, 0 fehlgeschlagen; Backup-API: 18 erfolgreich; Lint ohne Fehler. Neue IP-Regressionen prüfen IPv4/IPv6, gefälschte Clientangaben, vertrauenswürdige und fremde Proxys, Persistenz im Schlüsselbund, alte Datensatzformate, unbekannte IPs und Offline-Detailausgabe. Öffentliche Quellmanifest-Prüfung umfasst weiterhin 163 Dateien. Die Änderung der Standardgültigkeit ist mit `2.1.47` veröffentlicht; IP-Erweiterung bleibt unveröffentlicht. - 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 auf dem lokalen Desktop mit eingerichteten Schlüsselpfaden und Sicherungsnachweis aktualisiert. - 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. diff --git a/README.md b/README.md index 1320f66..f2d14f6 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,10 @@ To recover a lost transfer key, obtain a read-only copy of the corresponding ` ({ id, encryptedKey, createdAt, expiresAt: expiresAt ?? null })) + keys: entries.map(({ id, encryptedKey, createdAt, expiresAt, sourceIp }) => ({ id, encryptedKey, createdAt, expiresAt: expiresAt ?? null, ...(sourceIp ? { sourceIp } : {}) })) }); const payload = canonicalPayload(parseDocument(contents)); const stagingPath = temporaryPath('staging'); @@ -455,11 +460,12 @@ function createOnlineBackupKeyring({ } const entries = activeEntries .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - .map(({ id, key, createdAt, expiresAt }) => Object.freeze({ + .map(({ id, key, createdAt, expiresAt, sourceIp }) => Object.freeze({ id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, createdAt, - expiresAt + expiresAt, + ...(sourceIp ? { sourceIp } : {}) })); return Object.freeze({ entries: Object.freeze(entries), diff --git a/lib/online-backup-manager.js b/lib/online-backup-manager.js index 259131a..cafddcf 100644 --- a/lib/online-backup-manager.js +++ b/lib/online-backup-manager.js @@ -29,7 +29,8 @@ function sanitizeEntry(entry) { id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt, - expiresAt: entry.expiresAt ?? null + expiresAt: entry.expiresAt ?? null, + ...(entry.sourceIp ? { sourceIp: entry.sourceIp } : {}) }; } @@ -38,7 +39,8 @@ function sanitizeCreatedEntry(entry, key) { id: entry.id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, createdAt: entry.createdAt, - expiresAt: entry.expiresAt ?? null + expiresAt: entry.expiresAt ?? null, + ...(entry.sourceIp ? { sourceIp: entry.sourceIp } : {}) }; } @@ -80,8 +82,9 @@ function createOnlineBackupManager({ const settings = await loadSettings(); 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); + let prepared = keyring.prepare(created.key, createdAt, created.expiresAt ?? null); + const metadata = await uploadBackup(created.record); + if (metadata?.sourceIp) prepared = Object.freeze({ ...prepared, sourceIp: metadata.sourceIp }); try { await keyring.commit(prepared); } catch (error) { diff --git a/lib/online-backup.js b/lib/online-backup.js index 9a0e282..a867305 100644 --- a/lib/online-backup.js +++ b/lib/online-backup.js @@ -1,5 +1,6 @@ const crypto = require('node:crypto'); const zlib = require('node:zlib'); +const { isIP } = require('node:net'); const ONLINE_BACKUP_API_URL = 'https://uploader.24-music.de/backup-api'; const KEY_PREFIX = 'MHU2-'; @@ -238,12 +239,17 @@ async function downloadRecoveryPublicKey(baseUrl = ONLINE_BACKUP_API_URL, option } async function uploadOnlineBackup(record, baseUrl = ONLINE_BACKUP_API_URL, options) { - const { response } = await requestText(endpoint(baseUrl, '/v1/backups'), { + const { response, body } = 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'); + try { + const metadata = JSON.parse(body); + if (typeof metadata.sourceIp === 'string' && isIP(metadata.sourceIp)) return { sourceIp: metadata.sourceIp }; + } catch {} + return {}; } async function downloadOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options) { diff --git a/renderer/app.js b/renderer/app.js index 73e9df6..a1f54e3 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3691,7 +3691,8 @@ function normalizeManagedOnlineBackups(entries) { for (const entry of Array.isArray(entries) ? entries : []) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; const shape = Object.keys(entry).sort().join(','); - if (shape !== 'createdAt,displayKey,id' && shape !== 'createdAt,displayKey,expiresAt,id') continue; + if (shape !== 'createdAt,displayKey,id' && shape !== 'createdAt,displayKey,expiresAt,id' && shape !== 'createdAt,displayKey,expiresAt,id,sourceIp') continue; + if (shape.includes('sourceIp') && (typeof entry.sourceIp !== 'string' || !/^[0-9a-fA-F:.]{2,45}$/.test(entry.sourceIp))) continue; if (!isCanonicalManagedOnlineBackupId(entry.id)) continue; if (typeof entry.displayKey !== 'string' || !/^MHU2-[A-Za-z0-9_-]{4}…[A-Za-z0-9_-]{4}$/.test(entry.displayKey)) continue; const createdAt = new Date(entry.createdAt); @@ -3703,7 +3704,7 @@ function normalizeManagedOnlineBackups(entries) { expiresAt = entry.expiresAt; } if (expiresAt !== null && new Date(expiresAt).getTime() <= Date.now()) continue; - candidates.push({ id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt, expiresAt }); + candidates.push({ id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt, expiresAt, ...(entry.sourceIp ? { sourceIp: entry.sourceIp } : {}) }); } const counts = new Map(); for (const entry of candidates) counts.set(entry.id, (counts.get(entry.id) || 0) + 1); @@ -3784,7 +3785,7 @@ function renderManagedOnlineBackups(focusTarget = undefined) { const created = document.createElement('span'); created.className = 'online-backup-managed-created'; const createdLabel = document.createElement('span'); - createdLabel.textContent = `${localizeUiText('Erstellt')}: ${formatDateTime(entry.createdAt).text}`; + createdLabel.textContent = `${localizeUiText('Erstellt')}: ${formatDateTime(entry.createdAt).text} | ${entry.sourceIp ? `IP: ${entry.sourceIp}` : localizeUiText('IP unbekannt')}`; const expirationLabel = document.createElement('span'); expirationLabel.textContent = entry.expiresAt ? `${localizeUiText('Gültig bis')}: ${formatDateTime(entry.expiresAt).text}` diff --git a/renderer/i18n.js b/renderer/i18n.js index a8b925d..619f031 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -385,6 +385,7 @@ ['Unbegrenzt (Standard)', 'Unlimited (default)'], ['31 Tage', '31 days'], ['Erstellt', 'Created'], + ['IP unbekannt', 'IP unknown'], ['Gültig bis', 'Valid until'], ['Unbegrenzt gültig', 'Valid indefinitely'], ['Auf diesem Gerät erstellt', 'Created on this device'], diff --git a/renderer/styles.css b/renderer/styles.css index 32d02a4..6a65967 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -2193,6 +2193,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } .online-backup-managed-created { display: grid; + overflow-wrap: anywhere; gap: 3px; min-width: 0; color: var(--text-dim); diff --git a/scripts/backup-recovery.cjs b/scripts/backup-recovery.cjs index b9f9c5f..c13d128 100644 --- a/scripts/backup-recovery.cjs +++ b/scripts/backup-recovery.cjs @@ -1,6 +1,7 @@ const { generateKeyPairSync } = require('node:crypto'); const { mkdir, readFile, writeFile } = require('node:fs/promises'); const path = require('node:path'); +const { isIP } = require('node:net'); const { recoverBackupKey } = require('../lib/online-backup-recovery'); async function main() { @@ -18,17 +19,25 @@ async function main() { 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)); + if (command === 'recover' && (args.length === 3 || (args.length === 4 && args[3] === '--details'))) { + const [recordFile, privateFile, outputFile] = args.slice(0, 3).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 }); + let output = key; + if (args[3] === '--details') { + const date = new Date(record.createdAt); + if (!Number.isFinite(date.getTime())) throw new Error('Invalid creation date'); + const formatted = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(date).replace(', ', ' - '); + const sourceIp = typeof record.sourceIp === 'string' && isIP(record.sourceIp) ? record.sourceIp : 'unbekannt'; + output = `${formatted} | IP: ${sourceIp} | ${key}`; + } + await writeFile(outputFile, `${output}\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 | recover '); + throw new Error('Aufruf: node scripts/backup-recovery.cjs init | recover [--details]'); } main().catch(() => { diff --git a/services/backup-api/src/server.mjs b/services/backup-api/src/server.mjs index 031f6ec..e8c943c 100644 --- a/services/backup-api/src/server.mjs +++ b/services/backup-api/src/server.mjs @@ -261,7 +261,7 @@ async function recordExists(rootDir, id) { } } -async function createRecord(rootDir, payload, maxStorageBytes, maxRecords, nowMs) { +async function createRecord(rootDir, payload, maxStorageBytes, maxRecords, nowMs, sourceIp) { await mkdir(rootDir, { recursive: true }) await cleanupTemporaryFiles(rootDir) await cleanupExpiredRecords(rootDir, nowMs) @@ -271,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: payload.recovery ? 3 : 2, + version: 4, + sourceIp, ...(payload.recovery ? { recovery: payload.recovery } : {}), blob: payload.blob, deleteVerifier: payload.deleteVerifier, @@ -342,7 +343,10 @@ async function readRecord(rootDir, id) { && isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern) && isCanonicalTimestamp(record.createdAt) const expiring = ((keys === 'blob,createdAt,deleteVerifier,expiresAt,version' && record.version === 2) - || (keys === 'blob,createdAt,deleteVerifier,expiresAt,recovery,version' && record.version === 3 && validRecovery(record.recovery))) + || (keys === 'blob,createdAt,deleteVerifier,expiresAt,recovery,version' && record.version === 3 && validRecovery(record.recovery)) + || (record.version === 4 && typeof record.sourceIp === 'string' && isIP(record.sourceIp) + && (keys === 'blob,createdAt,deleteVerifier,expiresAt,sourceIp,version' + || (keys === 'blob,createdAt,deleteVerifier,expiresAt,recovery,sourceIp,version' && validRecovery(record.recovery))))) && validBlob && isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern) && isCanonicalTimestamp(record.createdAt) @@ -620,9 +624,15 @@ export function createBackupServer(options) { sendJson(response, 429, { error: 'rate_limited' }) return } + let createdAtMs + const sourceIp = address.startsWith('::ffff:') && isIP(address.slice(7)) === 4 ? address.slice(7) : address + if (!isIP(sourceIp)) throw new Error('Invalid client address') const result = await runStorageMutation(() => withStorageLock( options.rootDir, - () => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords, Number(now())) + () => { + createdAtMs = Number(now()) + return createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords, createdAtMs, sourceIp) + } )) if (result === 'duplicate') { sendJson(response, 409, { error: 'already_exists' }) @@ -632,7 +642,7 @@ export function createBackupServer(options) { sendJson(response, 507, { error: 'insufficient_storage' }) return } - sendJson(response, 201, { created: true }) + sendJson(response, 201, { created: true, sourceIp, createdAt: new Date(createdAtMs).toISOString() }) return } finally { bodyConcurrency.leave(address) diff --git a/services/backup-api/test/server.test.mjs b/services/backup-api/test/server.test.mjs index def81f6..7e0571b 100644 --- a/services/backup-api/test/server.test.mjs +++ b/services/backup-api/test/server.test.mjs @@ -76,6 +76,7 @@ test('creates immutable ciphertext records and restores them after a restart', a body: JSON.stringify(backup.payload) }) assert.equal(created.status, 201) + assert.equal((await created.json()).sourceIp, '127.0.0.1') await new Promise((resolve) => api.server.close(resolve)) api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] }) @@ -157,7 +158,7 @@ test('expires finite backups at the exact deadline and removes their ciphertext' assert.equal(created.status, 201) const storedPath = join(api.rootDir, `${backup.payload.id}.json`) const stored = JSON.parse(await readFile(storedPath, 'utf8')) - assert.equal(stored.version, 2) + assert.equal(stored.version, 4) assert.equal(stored.expiresAt, '2026-09-02T10:00:00.000Z') const beforeDeadline = await request(api, '/v1/backups/restore', { @@ -347,6 +348,35 @@ test('uses the last forwarded address from an explicitly trusted proxy', async ( assert.equal((await restore('198.51.100.2, 203.0.113.9')).status, 429) }) +test('stores only server-derived source IP and keeps it out of public restore responses', async t => { + for (const [trustedProxy, forwarded, expected] of [ + [false, '198.51.100.2', '127.0.0.1'], + [true, '198.51.100.2, 203.0.113.9', '203.0.113.9'], + [true, '2001:db8::42', '2001:db8::42'], + [true, 'invalid', '127.0.0.1'] + ]) { + const api = await startApi({ trustedProxy, trustedProxyAddresses: ['127.0.0.1'] }) + t.after(() => api.close()) + const backup = fixture() + const create = payload => request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded }, body: JSON.stringify(payload) }) + assert.equal((await create({ ...backup.payload, sourceIp: '192.0.2.1' })).status, 400) + const created = await create(backup.payload) + assert.equal(created.status, 201) + const metadata = await created.json() + assert.equal(metadata.sourceIp, expected) + const file = join(api.rootDir, `${backup.payload.id}.json`) + const stored = JSON.parse(await readFile(file, 'utf8')) + assert.equal(stored.sourceIp, expected) + assert.equal(stored.createdAt, metadata.createdAt) + const restoreRecord = () => request(api, '/v1/backups/restore', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: backup.payload.id }) }) + assert.deepEqual(await (await restoreRecord()).json(), { blob: backup.payload.blob }) + delete stored.sourceIp + stored.version = 2 + await writeFile(file, JSON.stringify(stored)) + assert.equal((await restoreRecord()).status, 200) + } +}) + test('keeps concurrency leases until storage mutations finish', async (t) => { const api = await startApi({ maxConcurrentPerClient: 1, maxConcurrentTotal: 1 }) t.after(() => api.close()) diff --git a/tests/i18n.test.js b/tests/i18n.test.js index 25e85e1..04d6608 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -6,6 +6,14 @@ const espree = require('espree'); const { normalizeLanguage, translateText } = require('../renderer/i18n'); +test('online backup metadata displays source IP without exposing full keys', () => { + assert.equal(translateText('IP unbekannt', 'en'), 'IP unknown'); + assert.equal(translateText('IP unknown', 'de'), 'IP unbekannt'); + const source = fs.readFileSync(path.join(__dirname, '../renderer/app.js'), 'utf8'); + assert.ok(source.includes('`IP: ${entry.sourceIp}`')); + assert.ok(source.includes('key.textContent = entry.displayKey;')); +}); + test('online backup selection defaults to unlimited in both languages', () => { assert.equal(translateText('Unbegrenzt (Standard)', 'en'), 'Unlimited (default)'); assert.equal(translateText('Unlimited (default)', 'de'), 'Unbegrenzt (Standard)'); diff --git a/tests/online-backup-keyring.test.js b/tests/online-backup-keyring.test.js index 6d14bc1..5c4f707 100644 --- a/tests/online-backup-keyring.test.js +++ b/tests/online-backup-keyring.test.js @@ -70,6 +70,20 @@ function writeKeyring(filePath, keys, generation = null) { } describe('encrypted online backup keyring', () => { + it('persists valid source IPs across reloads alongside legacy entries', async () => { + const { keyring, filePath } = fixture(); + const first = { ...keyring.prepare(validKey(), timestamp), sourceIp: '2001:db8::42' }; + await keyring.commit(first); + await keyring.commit(keyring.prepare(validKey(), timestamp)); + const listed = await keyring.list(); + assert.equal(listed.issues.length, 0); + assert.equal(listed.entries.find(entry => entry.id === first.id).sourceIp, '2001:db8::42'); + assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).keys.find(entry => entry.id === first.id).sourceIp, '2001:db8::42'); + await assert.rejects(keyring.commit({ ...keyring.prepare(validKey(), timestamp), sourceIp: 'invalid' })); + const removal = await keyring.prepareRemove(first.id); + await keyring.commitRemove(removal); + assert.equal((await keyring.list()).entries.length, 1); + }); it('persists the spec keys schema without plaintext and returns frozen sanitized entries', async () => { const { filePath, keyring } = fixture(); const key = validKey(); diff --git a/tests/online-backup-manager.test.js b/tests/online-backup-manager.test.js index 6bab38d..5132e8d 100644 --- a/tests/online-backup-manager.test.js +++ b/tests/online-backup-manager.test.js @@ -60,7 +60,8 @@ function createFixture(overrides = {}) { id: entry.id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, createdAt: entry.createdAt, - expiresAt: entry.expiresAt ?? null + expiresAt: entry.expiresAt ?? null, + ...(entry.sourceIp ? { sourceIp: entry.sourceIp } : {}) }); }, getKey: async (entryId) => { @@ -101,6 +102,7 @@ function createFixture(overrides = {}) { events.push('upload'); assert.equal(uploadedRecord, record); if (overrides.uploadError) throw overrides.uploadError; + return overrides.uploadMetadata; }, deleteBackup: async (value) => { events.push(`delete:${value}`); @@ -176,6 +178,15 @@ function deleteVerifier(value) { } describe('transactional online backup manager', () => { + it('keeps server-provided source IP in the committed and listed metadata', async () => { + const fixture = createFixture({ initialKey: null, uploadMetadata: { sourceIp: '2001:db8::1' } }); + const created = await fixture.manager.createManaged(); + assert.equal(created.ok, true); + assert.equal(created.entry.sourceIp, '2001:db8::1'); + const listed = await fixture.manager.listManaged(); + assert.equal(listed.entries[0].sourceIp, '2001:db8::1'); + assert.ok(!JSON.stringify(listed).includes(key)); + }); it('creates in prepare, upload, commit order and returns only the sanitized entry', async () => { const fixture = createFixture({ initialKey: null }); diff --git a/tests/online-backup-recovery.test.js b/tests/online-backup-recovery.test.js index bfe2930..4414656 100644 --- a/tests/online-backup-recovery.test.js +++ b/tests/online-backup-recovery.test.js @@ -36,13 +36,14 @@ test('server persists recovery without plaintext secrets, supports normal import 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); + assert.deepEqual(await uploadOnlineBackup(backup.record, url), { sourceIp: '127.0.0.1' }); 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(record.version, 4); + assert.equal(record.sourceIp, '127.0.0.1'); 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'); @@ -53,6 +54,18 @@ test('server persists recovery without plaintext secrets, supports normal import assert.equal(result.status, 0, result.stderr); assert.equal((await readFile(output, 'utf8')).trim(), backup.key); assert.ok(!result.stdout.includes(backup.key)); + const detailedOutput = join(rootDir, 'details.txt'); + assert.equal(spawnSync(process.execPath, [cli, 'recover', recordPath, privateFile, detailedOutput, '--details']).status, 0); + const details = await readFile(detailedOutput, 'utf8'); + assert.match(details, /^\d{2}\.\d{2}\.\d{4} - \d{2}:\d{2} \| IP: 127\.0\.0\.1 \| MHU2-/); + assert.ok(details.trim().endsWith(backup.key)); + const legacyRecord = { ...record, version: 3 }; + delete legacyRecord.sourceIp; + await writeFile(recordPath, JSON.stringify(legacyRecord)); + assert.equal((await downloadOnlineBackup(backup.key, url)).settings.language, 'de'); + const legacyDetails = join(rootDir, 'legacy-details.txt'); + assert.equal(spawnSync(process.execPath, [cli, 'recover', recordPath, privateFile, legacyDetails, '--details']).status, 0); + assert.ok((await readFile(legacyDetails, 'utf8')).includes(' | IP: unbekannt | ')); assert.equal(spawnSync(process.execPath, [cli, 'recover', recordPath, privateFile, output]).status, 1); const other = createOnlineBackup({}, 'test', undefined, 'forever', publicKey); await uploadOnlineBackup(other.record, url);