/** * Backup artifact encryption. * * The load-bearing test here is `writes a streamed artifact, not a JSON * envelope`. Everything else could pass while someone quietly reintroduces * the whole-file-in-memory path — the format assertion is what goes red if * they do, without needing an 800 MB fixture to prove it. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { randomBytes } from 'node:crypto'; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { encryptSecret } from '../secrets/encryption'; import { ARTIFACT_FORMAT_VERSION, ARTIFACT_MAGIC, decryptFileToFile, encryptFileToFile, isStreamedArtifact, } from './backup-cipher'; const MASTER_KEY = Buffer.alloc(32, 7); const OTHER_KEY = Buffer.alloc(32, 9); let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'celilo-cipher-test-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); function paths(name: string) { return { plain: join(dir, `${name}.tar`), enc: join(dir, `${name}.enc`), out: join(dir, `${name}.out`), }; } /** An artifact in the pre-streaming format: JSON envelope of base64-of-hex. */ function writeLegacyArtifact(path: string, payload: Buffer): void { writeFileSync(path, JSON.stringify(encryptSecret(payload.toString('base64'), MASTER_KEY))); } describe('backup-cipher', () => { test('round-trips a payload byte-for-byte', async () => { const p = paths('roundtrip'); const payload = randomBytes(3 * 1024 * 1024); writeFileSync(p.plain, payload); await encryptFileToFile(p.plain, p.enc, MASTER_KEY); await decryptFileToFile(p.enc, p.out, MASTER_KEY); expect(readFileSync(p.out).equals(payload)).toBe(true); }); test('writes a streamed artifact, not a JSON envelope', async () => { const p = paths('format'); writeFileSync(p.plain, randomBytes(4096)); await encryptFileToFile(p.plain, p.enc, MASTER_KEY); const written = readFileSync(p.enc); expect(written.subarray(0, ARTIFACT_MAGIC.length).equals(ARTIFACT_MAGIC)).toBe(true); expect(written[ARTIFACT_MAGIC.length]).toBe(ARTIFACT_FORMAT_VERSION); expect(isStreamedArtifact(p.enc)).toBe(true); // The old path produced base64-of-hex wrapped in JSON, which is 2.67x // the input and starts with '{'. Both are what made it OOM. expect(written[0]).not.toBe('{'.charCodeAt(0)); expect(written.length).toBeLessThan(4096 * 2); }); test('artifact is barely larger than its plaintext', async () => { const p = paths('overhead'); const size = 1024 * 1024; writeFileSync(p.plain, randomBytes(size)); await encryptFileToFile(p.plain, p.enc, MASTER_KEY); // header (25) + tag (16). AES-GCM is a stream cipher — no block padding. expect(statSync(p.enc).size).toBe(size + 41); }); test('still reads artifacts in the legacy JSON format', async () => { const p = paths('legacy'); const payload = randomBytes(64 * 1024); writeLegacyArtifact(p.enc, payload); expect(isStreamedArtifact(p.enc)).toBe(false); await decryptFileToFile(p.enc, p.out, MASTER_KEY); expect(readFileSync(p.out).equals(payload)).toBe(true); }); test('rejects a wrong master key rather than emitting garbage', async () => { const p = paths('wrongkey'); writeFileSync(p.plain, randomBytes(8192)); await encryptFileToFile(p.plain, p.enc, MASTER_KEY); expect(decryptFileToFile(p.enc, p.out, OTHER_KEY)).rejects.toThrow(); }); test('rejects tampered ciphertext', async () => { const p = paths('tamper'); writeFileSync(p.plain, randomBytes(8192)); await encryptFileToFile(p.plain, p.enc, MASTER_KEY); const bytes = readFileSync(p.enc); bytes[100] ^= 0xff; writeFileSync(p.enc, bytes); expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(); }); test('rejects a tampered auth tag', async () => { const p = paths('tamper-tag'); writeFileSync(p.plain, randomBytes(8192)); await encryptFileToFile(p.plain, p.enc, MASTER_KEY); const bytes = readFileSync(p.enc); bytes[bytes.length - 1] ^= 0xff; writeFileSync(p.enc, bytes); expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(); }); test('reports a truncated artifact clearly', async () => { const p = paths('truncated'); writeFileSync(p.enc, Buffer.concat([ARTIFACT_MAGIC, Buffer.from([ARTIFACT_FORMAT_VERSION])])); expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/truncated/i); }); test('refuses an artifact written by a newer celilo', async () => { const p = paths('future'); writeFileSync(p.plain, randomBytes(1024)); await encryptFileToFile(p.plain, p.enc, MASTER_KEY); const bytes = readFileSync(p.enc); bytes[ARTIFACT_MAGIC.length] = ARTIFACT_FORMAT_VERSION + 1; writeFileSync(p.enc, bytes); expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/Upgrade celilo/); }); test('handles an empty plaintext', async () => { const p = paths('empty'); writeFileSync(p.plain, Buffer.alloc(0)); await encryptFileToFile(p.plain, p.enc, MASTER_KEY); // Header + tag only — nothing to stream back, and a real tar is never // empty, so this is reported rather than silently producing 0 bytes. expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/no data/i); }); /** * The ceiling the old format could not clear: hex-of-base64 is 2.67 chars * per input byte against a ~2^31 max string length, so anything over * ~805 MB was unrepresentable regardless of available RAM. * * Opt-in — it writes ~900 MB to disk and takes tens of seconds, which does * not belong in every CI run. Run deliberately after touching this file: * CELILO_TEST_LARGE_BACKUP=1 bun test src/services/backup-cipher.test.ts */ test.skipIf(!process.env.CELILO_TEST_LARGE_BACKUP)( 'round-trips an artifact past the old format ceiling', async () => { const p = paths('huge'); const chunk = randomBytes(1024 * 1024); const file = Bun.file(p.plain).writer(); for (let i = 0; i < 900; i++) file.write(chunk); await file.end(); await encryptFileToFile(p.plain, p.enc, MASTER_KEY); await decryptFileToFile(p.enc, p.out, MASTER_KEY); expect(existsSync(p.out)).toBe(true); expect(statSync(p.out).size).toBe(statSync(p.plain).size); }, 300_000, ); });