This commit is contained in:
+5
-1
@@ -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.
|
||||
|
||||
@@ -203,6 +203,10 @@ To recover a lost transfer key, obtain a read-only copy of the corresponding `<r
|
||||
|
||||
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.
|
||||
|
||||
The next service update records the creating connection's IP address alongside the creation time. It trusts forwarding headers only from configured proxy peers; the public proxy must overwrite or append the actual client address. The IP represents the observed connection (possibly a VPN/NAT exit), not a verified device identity. IP metadata shares the backup's retention and deletion lifecycle and is not returned by the public restore endpoint. The client stores the creation response's IP in its local key list and displays it next to the creation time. Older backups without this metadata show an unknown IP; historical addresses are not inferred.
|
||||
|
||||
Append `--details` to the offline recovery command to write `22.09.2026 - 14:30 | IP: 203.0.113.10 | MHU2-...` instead of only the key. Detailed timestamps use Europe/Berlin. Paste only the `MHU2-...` part into the uploader. Without the flag, the output remains directly importable. This IP extension is not yet deployed. It introduces stored record version 4; the updated server still reads versions 1–3, but an older server cannot read new version-4 records. Do not roll back the server binary after creating such records without a data-preserving compatibility plan.
|
||||
|
||||
Treat the generated key like a password: anyone with it can restore the encrypted settings. Creating a new key does not invalidate older keys.
|
||||
|
||||
### Other network activity
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { isIP } = require('node:net');
|
||||
const secretStore = require('./secret-store');
|
||||
const { parseOnlineBackupKey } = require('./online-backup');
|
||||
|
||||
const STORED_LEGACY_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
|
||||
const STORED_EXPIRING_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'expiresAt', 'id'];
|
||||
const STORED_IP_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'expiresAt', 'id', 'sourceIp'];
|
||||
const STORED_V1_DOCUMENT_KEYS = ['keys', 'version'];
|
||||
const STORED_GENERATED_DOCUMENT_KEYS = ['generation', 'keys', 'version'];
|
||||
const KEYRING_ERROR_CODES = Object.freeze({
|
||||
@@ -181,13 +183,15 @@ function createOnlineBackupKeyring({
|
||||
|
||||
function validateEntry(entry) {
|
||||
const hasLegacyShape = hasExactKeys(entry, STORED_LEGACY_ENTRY_KEYS);
|
||||
const hasExpiringShape = hasExactKeys(entry, STORED_EXPIRING_ENTRY_KEYS);
|
||||
const hasIpShape = hasExactKeys(entry, STORED_IP_ENTRY_KEYS);
|
||||
const hasExpiringShape = hasExactKeys(entry, STORED_EXPIRING_ENTRY_KEYS) || hasIpShape;
|
||||
if (
|
||||
(!hasLegacyShape && !hasExpiringShape)
|
||||
|| !isCanonicalId(entry.id)
|
||||
|| typeof entry.encryptedKey !== 'string'
|
||||
|| !isEncrypted(entry.encryptedKey)
|
||||
|| typeof entry.createdAt !== 'string'
|
||||
|| (hasIpShape && (typeof entry.sourceIp !== 'string' || !isIP(entry.sourceIp)))
|
||||
) {
|
||||
return { issue: KEYRING_ERROR_CODES.structure, id: typeof entry?.id === 'string' ? entry.id : null };
|
||||
}
|
||||
@@ -228,6 +232,7 @@ function createOnlineBackupKeyring({
|
||||
encryptedKey: entry.encryptedKey,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
...(hasIpShape ? { sourceIp: entry.sourceIp } : {}),
|
||||
key
|
||||
}
|
||||
};
|
||||
@@ -413,7 +418,7 @@ function createOnlineBackupKeyring({
|
||||
const contents = JSON.stringify({
|
||||
version: 2,
|
||||
generation,
|
||||
keys: entries.map(({ id, encryptedKey, createdAt, expiresAt }) => ({ 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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+4
-3
@@ -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}`
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <neuer-Ordner> | recover <Datensatz.json> <private.pem> <neue-Ausgabedatei>');
|
||||
throw new Error('Aufruf: node scripts/backup-recovery.cjs init <neuer-Ordner> | recover <Datensatz.json> <private.pem> <neue-Ausgabedatei> [--details]');
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user