Add encrypted administrative recovery for online backup keys

This commit is contained in:
Sucukdeluxe
2026-09-22 04:34:27 +02:00
parent 5e15d4f864
commit 7f6adac78b
16 changed files with 318 additions and 14 deletions
+5 -1
View File
@@ -1,4 +1,5 @@
import { resolve } from 'node:path'
import { readFileSync } from 'node:fs'
import { createBackupServer } from './server.mjs'
const port = Number.parseInt(process.env.PORT ?? '8787', 10)
@@ -21,7 +22,10 @@ const trustedProxy = process.env.TRUST_PROXY === 'true'
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT')
const server = createBackupServer({ rootDir, allowedOrigins, rateLimit, uploadRateLimit, maxStorageBytes, trustedProxy })
const recoveryPublicKey = process.env.RECOVERY_PUBLIC_KEY_FILE
? readFileSync(resolve(process.env.RECOVERY_PUBLIC_KEY_FILE), 'utf8')
: undefined
const server = createBackupServer({ rootDir, allowedOrigins, rateLimit, uploadRateLimit, maxStorageBytes, trustedProxy, recoveryPublicKey })
server.listen(port, host, () => {
process.stdout.write(`Backup API listening on ${host}:${port}\n`)
@@ -0,0 +1,63 @@
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'
async function writeExclusive(file, text) {
const handle = await open(file, 'wx', 0o600)
try {
await handle.writeFile(text, 'utf8')
await handle.sync()
} finally {
await handle.close()
}
}
async function run() {
const [command, ...args] = process.argv.slice(2)
if (command === 'init' && args.length === 1) {
const directory = 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 writeExclusive(join(directory, 'private.pem'), pair.privateKey)
await writeExclusive(join(directory, 'public.pem'), pair.publicKey)
process.stdout.write(`Recovery key ID: ${recoveryDescriptor(pair.publicKey).keyId}\n`)
return
}
if (command === 'list' && args.length === 1) {
const directory = resolve(args[0])
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')
}
return
}
if (command === 'recover' && args.length === 4) {
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)
const privatePath = await realpath(privateFile)
const outputPath = join(await realpath(dirname(resolve(output))), basename(output))
for (const target of [privatePath, outputPath]) {
const rel = relative(directory, target)
if (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..\\') && !rel.startsWith('../')) throw new Error()
}
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')
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.exitCode = 1
}
run().catch(() => {
process.stderr.write('Recovery operation failed. Check arguments, permissions, key pairing and backup integrity. Existing files are never overwritten.\n')
process.exitCode = 1
})
+2
View File
@@ -0,0 +1,2 @@
export function recoveryDescriptor(pem: string): { version: number; keyId: string; publicKey: string };
export function recoverOnlineKey(record: { id: string; blob: string; deleteVerifier: string; recovery?: unknown }, privateKey: string): string;
+46
View File
@@ -0,0 +1,46 @@
import { constants, createDecipheriv, createHash, createPublicKey, hkdfSync, privateDecrypt, timingSafeEqual } from 'node:crypto'
export function recoveryDescriptor(pem) {
if (typeof pem !== 'string' || !pem.startsWith('-----BEGIN PUBLIC KEY-----')) throw new Error('Expected a public recovery key')
const key = createPublicKey(pem)
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails?.modulusLength !== 3072) throw new Error('Recovery requires RSA-3072')
return {
version: 1,
keyId: createHash('sha256').update(key.export({ type: 'spki', format: 'der' })).digest('base64url'),
publicKey: key.export({ type: 'spki', format: 'pem' }).toString()
}
}
export function recoveryLabel(record) {
return Buffer.from(`MDD-RECOVERY-V1:${record.id}:${record.deleteVerifier}:${createHash('sha256').update(record.blob).digest('base64url')}`)
}
export function validRecoveryEnvelope(envelope, descriptor) {
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return false
if (Object.keys(envelope).sort().join(',') !== 'ciphertext,keyId,version') return false
return envelope.version === 1 && envelope.keyId === descriptor?.keyId
&& typeof envelope.ciphertext === 'string' && /^[A-Za-z0-9_-]{512}$/.test(envelope.ciphertext)
&& Buffer.from(envelope.ciphertext, 'base64url').length === 384
}
export function recoverOnlineKey(record, privateKey) {
const publicKey = createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString()
if (!validRecoveryEnvelope(record.recovery, recoveryDescriptor(publicKey))) throw new Error('No recovery envelope for this private key')
const key = privateDecrypt({ key: privateKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256', oaepLabel: recoveryLabel(record) }, Buffer.from(record.recovery.ciphertext, 'base64url')).toString('utf8')
if (!/^MDD2-[A-Za-z0-9_-]{70}$/.test(key)) throw new Error('Invalid recovered key')
const decoded = Buffer.from(key.slice(5), 'base64url')
const id = decoded.subarray(0, 16)
const master = decoded.subarray(16, 48)
const checksum = createHash('sha256').update('MDD2-ONLINE-KEY-V1').update(id).update(master).digest().subarray(0, 4)
if (decoded.toString('base64url') !== key.slice(5) || id.toString('base64url') !== record.id || !timingSafeEqual(checksum, decoded.subarray(48))) throw new Error('Recovered key does not match record')
const secret = purpose => Buffer.from(hkdfSync('sha256', master, id, Buffer.from(`MDD-ONLINE-${purpose}-V1`), 32))
if (createHash('sha256').update(secret('DELETE')).digest('base64url') !== record.deleteVerifier) throw new Error('Invalid deletion verifier')
const blob = Buffer.from(record.blob, 'base64url')
if (blob[0] !== 1 || blob.length < 29) throw new Error('Invalid backup blob')
const decipher = createDecipheriv('aes-256-gcm', secret('ENCRYPTION'), blob.subarray(1, 13))
decipher.setAAD(Buffer.concat([Buffer.from('MDD-ONLINE-BACKUP-V1'), id]))
decipher.setAuthTag(blob.subarray(13, 29))
decipher.update(blob.subarray(29))
decipher.final()
return key
}
+1
View File
@@ -2,6 +2,7 @@ import type { Server } from "node:http";
export interface BackupServerOptions {
rootDir: string;
recoveryPublicKey?: string;
allowedOrigins?: string[];
rateLimit?: {
max: number;
+13 -3
View File
@@ -4,6 +4,7 @@ import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/prom
import { isIP } from 'node:net'
import { join } from 'node:path'
import lockfile from 'proper-lockfile'
import { recoveryDescriptor, validRecoveryEnvelope } from './recovery.mjs'
const maxBlobBytes = 256 * 1024
const maxBodyBytes = 384 * 1024
@@ -18,10 +19,11 @@ function isCanonicalBase64Url(value, byteLength, pattern) {
return decoded.length === byteLength && decoded.toString('base64url') === value
}
function isValidBackup(payload) {
function isValidBackup(payload, recovery) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
const keys = Object.keys(payload).sort()
if (keys.join(',') !== 'blob,deleteVerifier,id') return false
if (!['blob,deleteVerifier,id', 'blob,deleteVerifier,id,recovery'].includes(keys.join(','))) return false
if ('recovery' in payload && !validRecoveryEnvelope(payload.recovery, recovery)) return false
if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
@@ -187,6 +189,7 @@ async function createRecord(rootDir, payload, maxStorageBytes) {
version: 1,
blob: payload.blob,
deleteVerifier: payload.deleteVerifier,
...(payload.recovery ? { recovery: payload.recovery } : {}),
createdAt: new Date().toISOString()
}), 'utf8')
if (await directoryUsage(rootDir) + contents.length > maxStorageBytes) return 'full'
@@ -314,6 +317,7 @@ function clientAddress(request, trustedProxy) {
export function createBackupServer(options) {
if (!options?.rootDir) throw new Error('rootDir is required')
const allowedOrigins = new Set(options.allowedOrigins ?? [])
const recovery = options.recoveryPublicKey ? recoveryDescriptor(options.recoveryPublicKey) : null
const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024
@@ -367,6 +371,12 @@ export function createBackupServer(options) {
}
}
if (request.method === 'POST' && url.pathname === '/v1/backups/recovery-key') {
request.resume()
sendJson(response, recovery ? 200 : 503, recovery ?? { error: 'recovery_unavailable' })
return
}
if (request.method === 'POST' && ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete'].includes(url.pathname)) {
if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
sendJson(response, 415, { error: 'unsupported_media_type' })
@@ -428,7 +438,7 @@ export function createBackupServer(options) {
sendJson(response, 413, { error: 'payload_too_large' })
return
}
if (!isValidBackup(parsed.value)) {
if (!isValidBackup(parsed.value, recovery)) {
sendJson(response, 400, { error: 'invalid_request' })
return
}