diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index f013d5b..ecf3d2f 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -38,6 +38,11 @@ BACKUP_CRON_SCHEDULE="0 3 * * *" # Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach # jedem Lauf geloescht (Standard: 14). BACKUP_RETENTION_COUNT=14 +# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a +# key derived from JWT_ACCESS_SECRET and restore only into this deployment. +# Set the same value on another deployment to restore there. At least 32 +# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage. +# BACKUP_ENCRYPTION_KEY="" # Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert # (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). diff --git a/ApiCore-Backend/src/auth/reauth/reauth.module.ts b/ApiCore-Backend/src/auth/reauth/reauth.module.ts new file mode 100644 index 0000000..5b93675 --- /dev/null +++ b/ApiCore-Backend/src/auth/reauth/reauth.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MfaModule } from '../mfa/mfa.module'; +import { ReauthService } from './reauth.service'; + +@Module({ + imports: [MfaModule], + providers: [ReauthService], + exports: [ReauthService], +}) +export class ReauthModule {} diff --git a/ApiCore-Backend/src/auth/reauth/reauth.service.ts b/ApiCore-Backend/src/auth/reauth/reauth.service.ts new file mode 100644 index 0000000..cc8cf57 --- /dev/null +++ b/ApiCore-Backend/src/auth/reauth/reauth.service.ts @@ -0,0 +1,60 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { PrismaService } from '../../prisma/prisma.service'; +import { MfaService } from '../mfa/mfa.service'; + +export interface ReauthProof { + password?: string; + mfaCode?: string; +} + +/** + * Step-up check for actions that are as powerful as the account itself + * (restoring a backup overwrites every user): a stolen access token alone + * must not be enough, so the caller proves they know the password - and, + * with MFA on, holds the second factor - right now. + */ +@Injectable() +export class ReauthService { + constructor( + private readonly prisma: PrismaService, + private readonly mfaService: MfaService, + ) {} + + async verify(userId: string, proof: ReauthProof): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + email: true, + passwordHash: true, + mfaEnabled: true, + mfaSecret: true, + isActive: true, + }, + }); + const passwordMatches = + Boolean(user?.isActive) && + typeof proof.password === 'string' && + (await bcrypt.compare(proof.password, user!.passwordHash)); + if (!user || !passwordMatches) { + throw new UnauthorizedException( + 'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben', + ); + } + if (user.mfaEnabled) { + const codeMatches = + typeof proof.mfaCode === 'string' && + user.mfaSecret !== null && + (await this.mfaService.verifyCode( + user.mfaSecret, + user.email, + proof.mfaCode, + )); + if (!codeMatches) { + throw new UnauthorizedException( + 'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben', + ); + } + } + } +} diff --git a/ApiCore-Backend/src/backup/backup-crypto.spec.ts b/ApiCore-Backend/src/backup/backup-crypto.spec.ts new file mode 100644 index 0000000..6ebb65f --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-crypto.spec.ts @@ -0,0 +1,67 @@ +import { BadRequestException } from '@nestjs/common'; +import { isSealedBackup, openBackup, sealBackup } from './backup-crypto'; + +describe('backup-crypto', () => { + const originalEnv = { ...process.env }; + const bundle = { + version: 2, + exportedAt: '2026-10-01T09:00:00.000Z', + data: { user: [{ id: 'u1', passwordHash: '$2a$12$secret' }] }, + }; + + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = 'x'.repeat(40); + delete process.env.BACKUP_ENCRYPTION_KEY; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('round-trips a bundle and hides its contents', () => { + const sealed = sealBackup(bundle, bundle.exportedAt); + + expect(isSealedBackup(sealed)).toBe(true); + expect(JSON.stringify(sealed)).not.toContain('passwordHash'); + expect(JSON.stringify(sealed)).not.toContain('$2a$12$secret'); + expect(openBackup(sealed)).toEqual(bundle); + }); + + it('rejects a file whose ciphertext was changed', () => { + const sealed = sealBackup(bundle, bundle.exportedAt); + const bytes = Buffer.from(sealed.ciphertext, 'base64'); + bytes[0] ^= 0xff; + + expect(() => + openBackup({ ...sealed, ciphertext: bytes.toString('base64') }), + ).toThrow(BadRequestException); + }); + + it('rejects a file whose header was changed', () => { + const sealed = sealBackup(bundle, bundle.exportedAt); + + expect(() => + openBackup({ ...sealed, exportedAt: '2020-01-01T00:00:00.000Z' }), + ).toThrow(BadRequestException); + }); + + it('rejects a file sealed with another key', () => { + const sealed = sealBackup(bundle, bundle.exportedAt); + process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40); + + expect(() => openBackup(sealed)).toThrow(BadRequestException); + }); + + it('uses BACKUP_ENCRYPTION_KEY independently of the JWT secret', () => { + process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40); + const sealed = sealBackup(bundle, bundle.exportedAt); + process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); + + expect(openBackup(sealed)).toEqual(bundle); + }); + + it('refuses a too short BACKUP_ENCRYPTION_KEY', () => { + process.env.BACKUP_ENCRYPTION_KEY = 'short'; + expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow(); + }); +}); diff --git a/ApiCore-Backend/src/backup/backup-crypto.ts b/ApiCore-Backend/src/backup/backup-crypto.ts new file mode 100644 index 0000000..f09f617 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-crypto.ts @@ -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, +): 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; + 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; +} diff --git a/ApiCore-Backend/src/backup/backup-scheduler.service.ts b/ApiCore-Backend/src/backup/backup-scheduler.service.ts index 509922d..49e7700 100644 --- a/ApiCore-Backend/src/backup/backup-scheduler.service.ts +++ b/ApiCore-Backend/src/backup/backup-scheduler.service.ts @@ -144,7 +144,7 @@ export class BackupSchedulerService implements OnModuleInit { // Manual trigger (e.g. "Jetzt sichern" button) - always runs, no // duplicate-tick guard, since a human explicitly asked for it. - async runNow(): Promise<{ filePath: string }> { + async runNow(): Promise<{ fileName: string }> { const settings = await this.effectiveSettings(); if (!settings.path) { throw new Error( @@ -153,7 +153,8 @@ export class BackupSchedulerService implements OnModuleInit { } const filePath = await this.writeBackup(settings.path); await this.pruneOldBackups(settings.path, settings.retentionCount); - return { filePath }; + // Only the name: the server's directory layout stays on the server. + return { fileName: path.basename(filePath) }; } private async runScheduled(directory: string): Promise { @@ -176,14 +177,15 @@ export class BackupSchedulerService implements OnModuleInit { } private async writeBackup(directory: string): Promise { - const bundle = await this.backupService.exportAll(); - await fs.mkdir(directory, { recursive: true }); - const timestamp = bundle.exportedAt.replace(/[:.]/g, '-'); + const sealed = await this.backupService.exportSealed(); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const timestamp = sealed.exportedAt.replace(/[:.]/g, '-'); const filePath = path.join( directory, `${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`, ); - await fs.writeFile(filePath, JSON.stringify(bundle, null, 2)); + // Owner-only: the file holds every account's credentials (encrypted). + await fs.writeFile(filePath, JSON.stringify(sealed), { mode: 0o600 }); return filePath; } diff --git a/ApiCore-Backend/src/backup/backup.controller.ts b/ApiCore-Backend/src/backup/backup.controller.ts index 663bc51..d2d8c23 100644 --- a/ApiCore-Backend/src/backup/backup.controller.ts +++ b/ApiCore-Backend/src/backup/backup.controller.ts @@ -16,7 +16,9 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { BackupSettings } from '../../generated/prisma'; -import { BackupBundle, BackupService } from './backup.service'; +import { BackupService } from './backup.service'; +import { ReauthService } from '../auth/reauth/reauth.service'; +import { BackupImportProofDto } from './dto/backup-import-proof.dto'; import { BackupScheduleStatus, BackupSchedulerService, @@ -38,6 +40,7 @@ export class BackupController { private readonly backupService: BackupService, private readonly backupSchedulerService: BackupSchedulerService, private readonly backupSettingsService: BackupSettingsService, + private readonly reauth: ReauthService, ) {} @RequirePermission('backup:export') @@ -48,7 +51,7 @@ export class BackupController { @RequirePermission('backup:export') @Post('schedule/run-now') - runScheduleNow(): Promise<{ filePath: string }> { + runScheduleNow(): Promise<{ fileName: string }> { return this.backupSchedulerService.runNow(); } @@ -72,11 +75,12 @@ export class BackupController { @RequirePermission('backup:export') @Get('export') async export(@Res() res: Response): Promise { - const bundle = await this.backupService.exportAll(); + const sealed = await this.backupService.exportSealed(); const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); - res.send(JSON.stringify(bundle, null, 2)); + res.setHeader('Cache-Control', 'no-store'); + res.send(JSON.stringify(sealed)); } @RequirePermission('backup:import') @@ -99,17 +103,20 @@ export class BackupController { ) async import( @UploadedFile() file: Express.Multer.File | undefined, + @Body() proof: BackupImportProofDto, @CurrentUser() actor: AuthenticatedUser, ) { + // Restoring overwrites every account: prove it is really you, now. + await this.reauth.verify(actor.id, proof); if (!file) { throw new BadRequestException('No file uploaded'); } - let bundle: BackupBundle; + let parsed: unknown; try { - bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle; + parsed = JSON.parse(file.buffer.toString('utf-8')); } catch { throw new BadRequestException('Datei ist kein gültiges JSON'); } - return this.backupService.importAll(bundle, actor.id); + return this.backupService.importFile(parsed, actor.id); } } diff --git a/ApiCore-Backend/src/backup/backup.module.ts b/ApiCore-Backend/src/backup/backup.module.ts index a825a05..89eca13 100644 --- a/ApiCore-Backend/src/backup/backup.module.ts +++ b/ApiCore-Backend/src/backup/backup.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; +import { ReauthModule } from '../auth/reauth/reauth.module'; import { BackupController } from './backup.controller'; import { BackupService } from './backup.service'; import { BackupSchedulerService } from './backup-scheduler.service'; import { BackupSettingsService } from './backup-settings.service'; @Module({ + imports: [ReauthModule], controllers: [BackupController], providers: [BackupService, BackupSchedulerService, BackupSettingsService], }) diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index 47ae7e4..e301e32 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { BackupService } from './backup.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; jest.mock('node:fs/promises', () => ({ readFile: jest.fn(), @@ -69,6 +70,8 @@ describe('BackupService', () => { user: { upsert: jest.Mock; findMany: jest.Mock }; }; let auditLog: { record: jest.Mock }; + let permissionResolver: { invalidateAll: jest.Mock }; + const TRUSTED = { trusted: true }; beforeEach(() => { jest.clearAllMocks(); @@ -92,6 +95,9 @@ describe('BackupService', () => { findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }), }; tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]); + (tx.role as unknown as { findMany: jest.Mock }).findMany = jest + .fn() + .mockResolvedValue([]); prisma = { user: { @@ -114,9 +120,11 @@ describe('BackupService', () => { auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + permissionResolver = { invalidateAll: jest.fn() }; service = new BackupService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + permissionResolver as unknown as PermissionResolverService, ); }); @@ -198,7 +206,7 @@ describe('BackupService', () => { describe('importAll', () => { it('rejects a bundle with no data object', async () => { await expect( - service.importAll({ version: 2 } as never, 'admin-1'), + service.importAll({ version: 2 } as never, 'admin-1', TRUSTED), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.$transaction).not.toHaveBeenCalled(); }); @@ -211,6 +219,7 @@ describe('BackupService', () => { data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] }, }, 'admin-1', + TRUSTED, ); const [[call]] = tx.role.upsert.mock.calls as [ @@ -233,6 +242,7 @@ describe('BackupService', () => { }, }, 'admin-1', + TRUSTED, ); const [[call]] = tx.user.upsert.mock.calls as [ @@ -252,6 +262,7 @@ describe('BackupService', () => { }, }, 'admin-1', + TRUSTED, ), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -275,6 +286,7 @@ describe('BackupService', () => { }, }, 'admin-1', + TRUSTED, ); expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1); @@ -293,6 +305,7 @@ describe('BackupService', () => { }, }, 'admin-1', + TRUSTED, ); expect(tx.queen.upsert).toHaveBeenCalledTimes(2); @@ -326,6 +339,7 @@ describe('BackupService', () => { }, }, 'admin-1', + TRUSTED, ); const [[call]] = tx.inspection.upsert.mock.calls as [ @@ -352,6 +366,7 @@ describe('BackupService', () => { }, }, 'admin-1', + TRUSTED, ); const [[call]] = tx.inspection.upsert.mock.calls as [ @@ -372,6 +387,7 @@ describe('BackupService', () => { }, }, 'admin-1', + TRUSTED, ); const [[call]] = tx.stockMovement.upsert.mock.calls as [ @@ -396,6 +412,7 @@ describe('BackupService', () => { }, }, 'admin-1', + TRUSTED, ); const [[call]] = tx.automationRule.upsert.mock.calls as [ @@ -422,14 +439,14 @@ describe('BackupService', () => { document: [ { id: 'doc-1', - entityType: 'Colony', + entityType: 'COLONY', mimeType: 'application/pdf', uploadedByUserId: 'known-user', - contentBase64: Buffer.from('hello').toString('base64'), + contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'), }, { id: 'doc-2', - entityType: 'Colony', + entityType: 'COLONY', mimeType: 'application/pdf', uploadedByUserId: null, contentBase64: null, @@ -438,6 +455,7 @@ describe('BackupService', () => { }, }, 'admin-1', + TRUSTED, ); expect(fs.writeFile).toHaveBeenCalledTimes(1); @@ -457,6 +475,7 @@ describe('BackupService', () => { data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] }, }, 'admin-1', + TRUSTED, ); expect(auditLog.record).toHaveBeenCalledWith( @@ -464,9 +483,111 @@ describe('BackupService', () => { entityType: 'Backup', action: 'CREATE', userId: 'admin-1', - afterData: expect.objectContaining({ apiary: 1 }) as unknown, + afterData: expect.objectContaining({ + imported: expect.objectContaining({ apiary: 1 }) as unknown, + }) as unknown, }), ); + expect(permissionResolver.invalidateAll).toHaveBeenCalled(); + }); + + it('refuses a document whose entityType would leave the storage root', async () => { + await expect( + service.importAll( + { + version: 2, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { + document: [ + { + id: 'doc-evil', + entityType: '../../app/dist', + mimeType: 'application/pdf', + contentBase64: Buffer.from('%PDF-1.4').toString('base64'), + }, + ], + }, + }, + 'admin-1', + TRUSTED, + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('refuses a document whose bytes do not match its declared type', async () => { + await expect( + service.importAll( + { + version: 2, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { + document: [ + { + id: 'doc-html', + entityType: 'COLONY', + mimeType: 'application/pdf', + contentBase64: Buffer.from('