Record upload IP and add dated administrative key reports
This commit is contained in:
@@ -4,6 +4,8 @@ Die API speichert clientseitig verschlüsselte Backups. Neue Clients hinterlegen
|
||||
|
||||
Jeder Export wird als eigener unveränderlicher Datensatz gespeichert. Es gibt keine automatische Ablaufzeit und ein neuer Export überschreibt oder löscht keine älteren Sicherungen.
|
||||
|
||||
Neue Datensätze speichern zusätzlich die beim Upload erkannte IP-Adresse als administratives Metadatum (`sourceIp`, außerhalb des verschlüsselten Blobs). Die IP wird zusammen mit dem Datensatz gelöscht und nicht über den öffentlichen Restore-Endpunkt ausgegeben. Sie bezeichnet die vom Dienst gesehene Upload-Verbindung, bei Proxy/VPN also möglicherweise deren Ausgangs-IP, nicht zwingend den ursprünglichen Rechner oder eine eindeutige Person. Vorhandene Datensätze werden nicht nachträglich ergänzt.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
@@ -58,7 +60,7 @@ Die folgenden Linux-Pfade sind Beispiele und müssen an die tatsächliche Instal
|
||||
node src/recovery-admin.mjs list /var/lib/mdd-backups
|
||||
```
|
||||
|
||||
`recoveryKeyId: null` bedeutet: keine Schlüsselkopie vorhanden. Bei mehreren Sicherungen dienen Erstellungszeitpunkt und Backup-ID zur Zuordnung; Inhalte oder Accountnamen werden nicht aufgelistet.
|
||||
`recoveryKeyId: null` bedeutet: keine Schlüsselkopie vorhanden. Bei mehreren Sicherungen dienen Erstellungszeitpunkt, `sourceIp` und Backup-ID zur Zuordnung; Inhalte oder Accountnamen werden nicht aufgelistet. `sourceIp: null` bedeutet, dass keine gültige IP gespeichert wurde. Bei `TRUST_PROXY=true` muss der vorgeschaltete, allein zugelassene Proxy den Forwarding-Header überschreiben. In der Downloader-Produktivkonfiguration bindet der Dienst nur an Loopback; Nginx setzt `X-Forwarded-For` ausdrücklich auf `$remote_addr` und übernimmt keine vom Client behauptete Adresse.
|
||||
|
||||
4. Als Administrator den Schlüssel in eine neue Datei außerhalb des Datenverzeichnisses schreiben:
|
||||
|
||||
@@ -68,4 +70,12 @@ Die folgenden Linux-Pfade sind Beispiele und müssen an die tatsächliche Instal
|
||||
|
||||
`BACKUP_ID` durch die ausgewählte ID ersetzen. Der Befehl prüft Schlüssel-Fingerabdruck, OAEP-Bindung, MDD2-Prüfsumme, Löschverifikator und AES-GCM-Authentizität des Backups. Er überschreibt keine Datei und gibt den Schlüssel nicht im Terminal aus. Die Ausgabedatei enthält den vollständigen MDD2-Schlüssel und ist wie ein Passwort zu behandeln. Sicher auf den eigenen Rechner übertragen, in MDD unter „Online-Schlüssel importieren“ verwenden und nicht in Chats oder Logs kopieren.
|
||||
|
||||
Für eine lesbare Zuordnung beim Auslesen `--with-metadata` ergänzen:
|
||||
|
||||
```sh
|
||||
node src/recovery-admin.mjs recover /var/lib/mdd-backups BACKUP_ID /root/mdd-recovery-keys/private.pem /root/mdd-online-key-info.txt --with-metadata
|
||||
```
|
||||
|
||||
Die geschützte Ausgabedatei enthält dann beispielsweise `22.09.2026 - 14:30 | IP: 203.0.113.7 | Schlüssel: MDD2-…`. Das Datum bezeichnet die Erstellung der Sicherung, dargestellt in `Europe/Berlin` mit Sommer-/Winterzeit. Ohne gespeicherte IP steht `IP: unbekannt`. Für den Import nur den `MDD2-…`-Teil kopieren. Ohne Option bleibt das bisherige reine Schlüsselformat für Import und Skripte unverändert.
|
||||
|
||||
Schlüsselrotation: Alte private Schlüssel sicher behalten, solange zugehörige Sicherungen existieren. Neue öffentliche Schlüssel gelten nur für neue Exporte; vorhandene Kopien werden nicht umgeschrieben. Bei Verlust des privaten Schlüssels sind die dazugehörigen Schlüsselkopien nicht wiederherstellbar. Der Dienst benötigt den privaten Schlüssel auch nach der Einrichtung nicht.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { generateKeyPairSync } from 'node:crypto'
|
||||
import { mkdir, open, readFile, readdir, realpath } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { recoverOnlineKey, recoveryDescriptor } from './recovery.mjs'
|
||||
import { formatRecoveryReport, recoveryMetadata } from './recovery-report.mjs'
|
||||
|
||||
async function writeExclusive(file, text) {
|
||||
const handle = await open(file, 'wx', 0o600)
|
||||
@@ -33,11 +34,11 @@ async function run() {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !/^[A-Za-z0-9_-]{22}\.json$/.test(entry.name)) continue
|
||||
const record = JSON.parse(await readFile(join(directory, entry.name), 'utf8'))
|
||||
process.stdout.write(JSON.stringify({ id: entry.name.slice(0, -5), createdAt: record.createdAt, recoveryKeyId: record.recovery?.keyId ?? null }) + '\n')
|
||||
process.stdout.write(JSON.stringify({ id: entry.name.slice(0, -5), ...recoveryMetadata(record), recoveryKeyId: record.recovery?.keyId ?? null }) + '\n')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (command === 'recover' && args.length === 4) {
|
||||
if (command === 'recover' && (args.length === 4 || (args.length === 5 && args[4] === '--with-metadata'))) {
|
||||
const [root, id, privateFile, output] = args
|
||||
if (!/^[A-Za-z0-9_-]{22}$/.test(id) || Buffer.from(id, 'base64url').toString('base64url') !== id) throw new Error()
|
||||
const directory = await realpath(root)
|
||||
@@ -49,11 +50,11 @@ async function run() {
|
||||
}
|
||||
const record = JSON.parse(await readFile(join(directory, `${id}.json`), 'utf8'))
|
||||
const key = recoverOnlineKey({ ...record, id }, await readFile(privatePath, 'utf8'))
|
||||
await writeExclusive(outputPath, key + '\n')
|
||||
await writeExclusive(outputPath, args[4] === '--with-metadata' ? formatRecoveryReport(record, key) : key + '\n')
|
||||
process.stdout.write('Online key written to the requested file. Protect it like a password.\n')
|
||||
return
|
||||
}
|
||||
process.stderr.write('Usage: recovery-admin.mjs init <new-key-directory> | list <backup-data-directory> | recover <backup-data-directory> <backup-id> <private.pem> <new-output-file>\n')
|
||||
process.stderr.write('Usage: recovery-admin.mjs init <new-key-directory> | list <backup-data-directory> | recover <backup-data-directory> <backup-id> <private.pem> <new-output-file> [--with-metadata]\n')
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isIP } from 'node:net'
|
||||
|
||||
export function recoveryMetadata(record) {
|
||||
let sourceIp = typeof record.sourceIp === 'string' && isIP(record.sourceIp) ? record.sourceIp : null
|
||||
if (sourceIp?.startsWith('::ffff:') && isIP(sourceIp.slice(7)) === 4) sourceIp = sourceIp.slice(7)
|
||||
return { createdAt: typeof record.createdAt === 'string' ? record.createdAt : null, sourceIp }
|
||||
}
|
||||
|
||||
export function formatRecoveryReport(record, key) {
|
||||
if (!/^MDD2-[A-Za-z0-9_-]{70}$/.test(key)) throw new Error('Invalid online key')
|
||||
const metadata = recoveryMetadata(record)
|
||||
const date = metadata.createdAt ? new Date(metadata.createdAt) : null
|
||||
let timestamp = 'Datum unbekannt'
|
||||
if (date && Number.isFinite(date.getTime())) {
|
||||
const parts = Object.fromEntries(new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
|
||||
}).formatToParts(date).map(part => [part.type, part.value]))
|
||||
timestamp = `${parts.day}.${parts.month}.${parts.year} - ${parts.hour}:${parts.minute}`
|
||||
}
|
||||
return `${timestamp} | IP: ${metadata.sourceIp ?? 'unbekannt'} | Schlüssel: ${key}\n`
|
||||
}
|
||||
@@ -182,7 +182,7 @@ async function recordExists(rootDir, id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createRecord(rootDir, payload, maxStorageBytes) {
|
||||
async function createRecord(rootDir, payload, maxStorageBytes, sourceIp) {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
if (await recordExists(rootDir, payload.id)) return 'duplicate'
|
||||
const contents = Buffer.from(JSON.stringify({
|
||||
@@ -190,7 +190,8 @@ async function createRecord(rootDir, payload, maxStorageBytes) {
|
||||
blob: payload.blob,
|
||||
deleteVerifier: payload.deleteVerifier,
|
||||
...(payload.recovery ? { recovery: payload.recovery } : {}),
|
||||
createdAt: new Date().toISOString()
|
||||
createdAt: new Date().toISOString(),
|
||||
sourceIp: isIP(sourceIp) ? sourceIp : null
|
||||
}), 'utf8')
|
||||
if (await directoryUsage(rootDir) + contents.length > maxStorageBytes) return 'full'
|
||||
const temporaryPath = join(rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
|
||||
@@ -450,7 +451,7 @@ export function createBackupServer(options) {
|
||||
}
|
||||
const result = await runStorageMutation(() => withStorageLock(
|
||||
options.rootDir,
|
||||
() => createRecord(options.rootDir, parsed.value, maxStorageBytes)
|
||||
() => createRecord(options.rootDir, parsed.value, maxStorageBytes, clientAddress(request, options.trustedProxy === true))
|
||||
))
|
||||
if (result === 'duplicate') {
|
||||
sendJson(response, 409, { error: 'already_exists' })
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { formatRecoveryReport, recoveryMetadata } from '../src/recovery-report.mjs'
|
||||
|
||||
const key = 'MDD2-' + 'A'.repeat(70)
|
||||
|
||||
test('formats date in Berlin time with source IP and the online key', () => {
|
||||
assert.equal(formatRecoveryReport({ createdAt: '2026-09-22T12:30:00.000Z', sourceIp: '203.0.113.7' }, key), `22.09.2026 - 14:30 | IP: 203.0.113.7 | Schlüssel: ${key}\n`)
|
||||
assert.equal(formatRecoveryReport({ createdAt: '2026-01-22T12:30:00.000Z', sourceIp: '2001:db8::7' }, key), `22.01.2026 - 13:30 | IP: 2001:db8::7 | Schlüssel: ${key}\n`)
|
||||
assert.equal(formatRecoveryReport({ createdAt: '2026-09-21T22:00:00.000Z', sourceIp: '::ffff:192.0.2.1' }, key), `22.09.2026 - 00:00 | IP: 192.0.2.1 | Schlüssel: ${key}\n`)
|
||||
})
|
||||
|
||||
test('keeps legacy metadata explicitly unknown and does not invent a date or IP', () => {
|
||||
assert.deepEqual(recoveryMetadata({}), { createdAt: null, sourceIp: null })
|
||||
assert.equal(formatRecoveryReport({}, key), `Datum unbekannt | IP: unbekannt | Schlüssel: ${key}\n`)
|
||||
assert.equal(formatRecoveryReport({ createdAt: 'invalid', sourceIp: 'invalid\nInjected' }, key), `Datum unbekannt | IP: unbekannt | Schlüssel: ${key}\n`)
|
||||
assert.throws(() => formatRecoveryReport({}, key + '\nInjected'))
|
||||
})
|
||||
@@ -61,6 +61,33 @@ test('health endpoint reports readiness without exposing storage details', async
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store')
|
||||
})
|
||||
|
||||
for (const [label, trustedProxy, forwarded, expected] of [
|
||||
['socket peer without proxy trust', false, '203.0.113.7', '127.0.0.1'],
|
||||
['trusted ingress IPv4', true, '203.0.113.7', '203.0.113.7'],
|
||||
['trusted ingress IPv6', true, '2001:db8::7', '2001:db8::7'],
|
||||
['invalid forwarding header', true, 'not-an-ip', '127.0.0.1']
|
||||
]) {
|
||||
test(`records source IP from ${label} without exposing it through restore`, async t => {
|
||||
const api = await startApi({ trustedProxy })
|
||||
t.after(() => api.close())
|
||||
const fixture = backupFixture()
|
||||
const response = await request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded }, body: JSON.stringify(fixture.payload) })
|
||||
assert.equal(response.status, 201)
|
||||
const filename = join(api.rootDir, `${fixture.payload.id}.json`)
|
||||
const original = await readFile(filename, 'utf8')
|
||||
const stored = JSON.parse(original)
|
||||
assert.equal(stored.sourceIp, expected)
|
||||
assert(Number.isFinite(Date.parse(stored.createdAt)))
|
||||
const duplicate = await request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': '192.0.2.9' }, body: JSON.stringify(fixture.payload) })
|
||||
assert.equal(duplicate.status, 409)
|
||||
assert.equal(await readFile(filename, 'utf8'), original)
|
||||
const restore = await request(api, '/v1/backups/restore', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: fixture.payload.id }) })
|
||||
assert.deepEqual(await restore.json(), { blob: fixture.payload.blob })
|
||||
const forged = await request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...backupFixture().payload, sourceIp: '192.0.2.99' }) })
|
||||
assert.equal(forged.status, 400)
|
||||
})
|
||||
}
|
||||
|
||||
test('health endpoint rejects an unusable storage path', async t => {
|
||||
const container = await mkdtemp(join(tmpdir(), 'mdd-backup-health-'))
|
||||
const rootDir = join(container, 'not-a-directory')
|
||||
|
||||
Reference in New Issue
Block a user