Record upload IP and add dated administrative key reports

This commit is contained in:
Sucukdeluxe
2026-09-22 04:57:09 +02:00
parent 76bd13a03d
commit aeff3193e6
8 changed files with 103 additions and 8 deletions
+11 -1
View File
@@ -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.
+5 -4
View File
@@ -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`
}
+4 -3
View File
@@ -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'))
})
+27
View File
@@ -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')