import { BadRequestException } from '@nestjs/common'; import * as crypto from 'node:crypto'; import * as zlib from 'node:zlib'; import { ENCRYPTION_KEY_VARIABLES, devFallbackSecret, findRingKey, gcmOpen, gcmSeal, readRingSecrets, requireJwtAccessSecret, type KeyRing, type RingKey, } from '../common/crypto/key-ring'; /** * A backup holds everything - password hashes, encrypted MFA secrets, * webhook signing secrets - and an import writes straight into the users * table. So the file is sealed: AES-256-GCM over the gzipped bundle, which * keeps it unreadable outside ApiCore AND makes any change to it (or a * hand-made file) fail authentication before a single row is touched. * * Keys follow the key ring (common/crypto/key-ring.ts): BACKUP_ENCRYPTION_KEY * seals, BACKUP_ENCRYPTION_KEY_PREVIOUS still opens, and files sealed before * the key was mandatory - with a key derived from JWT_ACCESS_SECRET - are * still read (never written, except outside production without * BACKUP_ENCRYPTION_KEY). The file names its key in `keyId`. Either way the * AES key comes from HKDF with a backup-specific label, never the raw * secret; derivation and file format v3 are unchanged, so older files open. */ export const SEALED_BACKUP_FORMAT = 'apicore-backup'; export const SEALED_BACKUP_VERSION = 3; // Decompressed size cap: a small file must not unpack into gigabytes. const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024; export interface SealedBackup { format: typeof SEALED_BACKUP_FORMAT; version: typeof SEALED_BACKUP_VERSION; algorithm: 'A256GCM+gzip'; /** Tells which key a file needs, without revealing the key. */ keyId: string; exportedAt: string; iv: string; tag: string; ciphertext: string; } type KeySource = 'backup' | 'jwt'; function deriveBackupKey(secret: string, source: KeySource): RingKey { const key = Buffer.from( crypto.hkdfSync( 'sha256', secret, 'apicore-backup', `apicore-backup-v${SEALED_BACKUP_VERSION}:${source}`, 32, ), ); const id = crypto.createHash('sha256').update(key).digest('hex').slice(0, 12); return { id, key }; } function legacyBackupKey(): RingKey { return deriveBackupKey(requireJwtAccessSecret(), 'jwt'); } function loadBackupRing(): KeyRing { const variable = ENCRYPTION_KEY_VARIABLES.backup; const secrets = readRingSecrets(variable); return { current: secrets.current ? deriveBackupKey(secrets.current, 'backup') : deriveBackupKey(devFallbackSecret(variable), 'jwt'), previous: secrets.previous ? deriveBackupKey(secrets.previous, 'backup') : null, }; } /** The ring's keys first; the JWT-derived key of older files only as a last resort for reading. */ function keyForReading(keyId: string): RingKey | null { const ringKey = findRingKey(loadBackupRing(), keyId); if (ringKey) { return ringKey; } const legacy = legacyBackupKey(); return legacy.id === keyId ? legacy : null; } function associatedData( header: Pick, ): Buffer { return Buffer.from( `${header.format}|${header.version}|${header.keyId}|${header.exportedAt}`, ); } export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup { const { current } = loadBackupRing(); const header = { format: SEALED_BACKUP_FORMAT, version: SEALED_BACKUP_VERSION, keyId: current.id, exportedAt, } as const; const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle))); const sealed = gcmSeal(current.key, compressed, associatedData(header)); return { ...header, algorithm: 'A256GCM+gzip', iv: sealed.iv.toString('base64'), tag: sealed.tag.toString('base64'), ciphertext: sealed.ciphertext.toString('base64'), }; } export function isSealedBackup(value: unknown): value is SealedBackup { if (typeof value !== 'object' || value === null) { return false; } const candidate = value as Record; return ( candidate.format === SEALED_BACKUP_FORMAT && typeof candidate.ciphertext === 'string' ); } /** The bundle inside a sealed backup; throws if no known key made the file or it was changed. */ export function openBackup(sealed: SealedBackup): unknown { if (sealed.version !== SEALED_BACKUP_VERSION) { throw new BadRequestException( `Backup-Version ${String(sealed.version)} wird nicht unterstützt`, ); } const ringKey = keyForReading(sealed.keyId); if (!ringKey) { throw new BadRequestException( 'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY bzw. BACKUP_ENCRYPTION_KEY_PREVIOUS prüfen)', ); } let compressed: Buffer; try { compressed = gcmOpen( ringKey.key, { iv: Buffer.from(sealed.iv, 'base64'), tag: Buffer.from(sealed.tag, 'base64'), ciphertext: Buffer.from(sealed.ciphertext, 'base64'), }, associatedData(sealed), ); } catch { throw new BadRequestException( 'Backup-Datei ist beschädigt oder wurde verändert', ); } let json: string; try { json = zlib .gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES }) .toString('utf-8'); } catch { throw new BadRequestException('Backup-Datei ist zu groß oder beschädigt'); } return JSON.parse(json) as unknown; }