/** * 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(); }); });