Backend-agnostic disaster-recovery backup engine that runs on the current storage backend (no storage cutover required): - Full-bundle export: db.dump (pg_dump custom) + every storage blob + manifest.json with per-object SHA-256, streamed as a tar. Entry points: admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts. - Admin-configurable push destinations (backup_destinations table, migration 0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM at rest; API returns only *IsSet markers. - Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) + scripts/decrypt-backup.ts for restore. - Wired the previously-dead database-backup cron to runScheduledBackupPush (push to enabled destinations, prune to retention, alert super-admins on failure). Tests: 1608 unit/integration pass; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
76 lines
2.6 KiB
TypeScript
76 lines
2.6 KiB
TypeScript
/**
|
|
* Unit test for opt-in backup-bundle encryption
|
|
* (`src/lib/services/backup-destinations/bundle-encryption.ts`).
|
|
*
|
|
* Contract: AES-256-GCM streaming encryption with a scrypt-derived key.
|
|
* - round-trips arbitrary bytes (small + multi-chunk),
|
|
* - rejects the wrong passphrase (GCM auth failure),
|
|
* - rejects tampered ciphertext (GCM auth failure).
|
|
*/
|
|
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
decryptFileToFile,
|
|
encryptFileToFile,
|
|
} from '@/lib/services/backup-destinations/bundle-encryption';
|
|
|
|
function sha(p: string): string {
|
|
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
|
}
|
|
|
|
describe('backup bundle encryption', () => {
|
|
let dir: string;
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(path.join(tmpdir(), 'pn-enc-'));
|
|
});
|
|
afterEach(() => {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('round-trips a multi-chunk file with the correct passphrase', async () => {
|
|
const plain = path.join(dir, 'bundle.tar');
|
|
// ~400 KB of pseudo-random bytes → multiple cipher chunks.
|
|
writeFileSync(plain, randomBytes(400 * 1024));
|
|
const enc = path.join(dir, 'bundle.tar.enc');
|
|
const dec = path.join(dir, 'bundle.roundtrip.tar');
|
|
|
|
await encryptFileToFile(plain, enc, 'correct horse battery staple');
|
|
// Ciphertext must differ from plaintext and not be empty.
|
|
expect(statSync(enc).size).toBeGreaterThan(0);
|
|
expect(sha(enc)).not.toBe(sha(plain));
|
|
|
|
await decryptFileToFile(enc, dec, 'correct horse battery staple');
|
|
expect(sha(dec)).toBe(sha(plain));
|
|
});
|
|
|
|
it('rejects the wrong passphrase', async () => {
|
|
const plain = path.join(dir, 'b.tar');
|
|
writeFileSync(plain, Buffer.from('top secret contract bytes'));
|
|
const enc = path.join(dir, 'b.tar.enc');
|
|
await encryptFileToFile(plain, enc, 'right-pass');
|
|
|
|
await expect(decryptFileToFile(enc, path.join(dir, 'out.tar'), 'WRONG-pass')).rejects.toThrow();
|
|
});
|
|
|
|
it('rejects tampered ciphertext', async () => {
|
|
const plain = path.join(dir, 'c.tar');
|
|
writeFileSync(plain, randomBytes(64 * 1024));
|
|
const enc = path.join(dir, 'c.tar.enc');
|
|
await encryptFileToFile(plain, enc, 'pw');
|
|
|
|
// Flip a byte in the middle of the ciphertext region.
|
|
const buf = readFileSync(enc);
|
|
const mid = Math.floor(buf.length / 2);
|
|
buf[mid] = (buf[mid] ?? 0) ^ 0xff;
|
|
writeFileSync(enc, buf);
|
|
|
|
await expect(decryptFileToFile(enc, path.join(dir, 'out.tar'), 'pw')).rejects.toThrow();
|
|
});
|
|
});
|