/** * Opt-in client-side encryption for backup bundles * (docs/superpowers/specs/2026-06-04-backup-destinations-design.md). * * When a destination has `encryptBundle` on, the tar is encrypted to * `.tar.enc` before it leaves this server, so a compromised destination * (untrusted SFTP host, third-party bucket) never holds raw signed contracts + * GDPR data. * * Format (AES-256-GCM, scrypt KDF): * * ┌────────┬──────────┬──────────┬──────────────┬──────────┐ * │ magic │ salt │ iv │ ciphertext … │ authTag │ * │ 5 bytes│ 16 bytes │ 12 bytes │ (streamed) │ 16 bytes │ * └────────┴──────────┴──────────┴──────────────┴──────────┘ * * Streaming throughout (memory stays O(chunk)). The auth tag is written last * because GCM only produces it after the final block; decryption reads it from * the file tail first, then streams the ciphertext through the decipher. */ import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb } from 'node:crypto'; import { createReadStream, createWriteStream } from 'node:fs'; import { open, stat } from 'node:fs/promises'; import { pipeline } from 'node:stream/promises'; import { promisify } from 'node:util'; const scrypt = promisify(scryptCb); const MAGIC = Buffer.from('PNBK1', 'ascii'); // 5 bytes const SALT_LEN = 16; const IV_LEN = 12; const TAG_LEN = 16; const HEADER_LEN = MAGIC.length + SALT_LEN + IV_LEN; // 33 async function deriveKey(passphrase: string, salt: Buffer): Promise { return (await scrypt(passphrase, salt, 32)) as Buffer; } /** Encrypt `srcPath` → `destPath` with a passphrase-derived AES-256-GCM key. */ export async function encryptFileToFile( srcPath: string, destPath: string, passphrase: string, ): Promise { const salt = randomBytes(SALT_LEN); const iv = randomBytes(IV_LEN); const key = await deriveKey(passphrase, salt); const cipher = createCipheriv('aes-256-gcm', key, iv); const out = createWriteStream(destPath); out.write(Buffer.concat([MAGIC, salt, iv])); // Pipe plaintext → cipher → file, writing to `out` by hand (rather than // letting pipeline end it) so we can append the auth tag once the cipher has // flushed its final block. await pipeline(createReadStream(srcPath), cipher, async (source) => { for await (const chunk of source) { if (!out.write(chunk as Buffer)) { await new Promise((resolve) => out.once('drain', () => resolve())); } } }); out.write(cipher.getAuthTag()); await new Promise((resolve, reject) => { out.end((err?: Error | null) => (err ? reject(err) : resolve())); }); } /** Decrypt a file produced by {@link encryptFileToFile}. Throws on wrong key / tamper. */ export async function decryptFileToFile( srcPath: string, destPath: string, passphrase: string, ): Promise { const { size } = await stat(srcPath); if (size < HEADER_LEN + TAG_LEN) { throw new Error('Encrypted backup is too small / not a PNBK1 bundle'); } // Read the fixed header + the trailing auth tag. const fh = await open(srcPath, 'r'); try { const header = Buffer.alloc(HEADER_LEN); await fh.read(header, 0, HEADER_LEN, 0); if (!header.subarray(0, MAGIC.length).equals(MAGIC)) { throw new Error('Not a PNBK1 encrypted backup (bad magic)'); } const salt = header.subarray(MAGIC.length, MAGIC.length + SALT_LEN); const iv = header.subarray(MAGIC.length + SALT_LEN, HEADER_LEN); const tag = Buffer.alloc(TAG_LEN); await fh.read(tag, 0, TAG_LEN, size - TAG_LEN); const key = await deriveKey(passphrase, salt); const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); // Stream only the ciphertext region [HEADER_LEN, size - TAG_LEN). const cipherStream = createReadStream(srcPath, { start: HEADER_LEN, end: size - TAG_LEN - 1, }); await pipeline(cipherStream, decipher, createWriteStream(destPath)); } finally { await fh.close(); } }