fix(backup): seal backups and require step-up auth to restore

Backups carried every password hash, encrypted MFA secret and webhook
secret in plain JSON, and an import upserted arbitrary rows - including
users - so anyone holding backup:import, or tricking an admin into
importing a file, could overwrite the admin account with a known password.

- Export (download and scheduled) is AES-256-GCM over the gzipped bundle,
  key from BACKUP_ENCRYPTION_KEY or derived from JWT_ACCESS_SECRET via
  HKDF; header is authenticated, decompression is capped. Scheduled files
  are written 0600 in a 0700 directory; run-now returns only the file name.
- Import requires the current password (and TOTP when MFA is on).
- Only a sealed file restores users, roles, grants, webhooks and audit
  entries; an old unsealed file restores business data only and reports
  what it skipped.
- Audit entries are append-only on import.
- Imported documents: entityType checked against the enum, MIME type
  allowlisted and matched against the file signature, path kept under the
  storage root (no more directory traversal via entityType).
- Permission cache is cleared after an import.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:12:43 +02:00
1 parent a25265b88a
commit bd592adf66
15 files changed
+636 -38

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
import { BadRequestException } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as zlib from 'node:zlib';
/**
* 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.
*
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
* can always restore its own backups. Either way the AES key comes from
* HKDF with a backup-specific label, never the raw secret.
*/
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
export const SEALED_BACKUP_VERSION = 3;
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const MIN_KEY_LENGTH = 32;
// 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;
}
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
if (backupKey) {
if (backupKey.length < MIN_KEY_LENGTH) {
throw new Error(
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
);
}
return { secret: backupKey, source: 'backup' };
}
const jwtSecret = process.env.JWT_ACCESS_SECRET;
if (!jwtSecret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return { secret: jwtSecret, source: 'jwt' };
}
function deriveKey(): { key: Buffer; keyId: string } {
const { secret, source } = rootSecret();
const key = Buffer.from(
crypto.hkdfSync(
'sha256',
secret,
'apicore-backup',
`apicore-backup-v${SEALED_BACKUP_VERSION}:${source}`,
32,
),
);
const keyId = crypto
.createHash('sha256')
.update(key)
.digest('hex')
.slice(0, 12);
return { key, keyId };
}
function associatedData(
header: Pick<SealedBackup, 'format' | 'version' | 'keyId' | 'exportedAt'>,
): Buffer {
return Buffer.from(
`${header.format}|${header.version}|${header.keyId}|${header.exportedAt}`,
);
}
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
const { key, keyId } = deriveKey();
const iv = crypto.randomBytes(IV_BYTES);
const header = {
format: SEALED_BACKUP_FORMAT,
version: SEALED_BACKUP_VERSION,
keyId,
exportedAt,
} as const;
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
cipher.setAAD(associatedData(header));
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
return {
...header,
algorithm: 'A256GCM+gzip',
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
};
}
export function isSealedBackup(value: unknown): value is SealedBackup {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
candidate.format === SEALED_BACKUP_FORMAT &&
typeof candidate.ciphertext === 'string'
);
}
/** The bundle inside a sealed backup; throws if the file was not made with this key or 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 { key, keyId } = deriveKey();
if (sealed.keyId !== keyId) {
throw new BadRequestException(
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
);
}
let compressed: Buffer;
try {
const decipher = crypto.createDecipheriv(
ALGORITHM,
key,
Buffer.from(sealed.iv, 'base64'),
);
decipher.setAAD(associatedData(sealed));
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
compressed = Buffer.concat([
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
decipher.final(),
]);
} 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;
}