feat(backup): full DR bundle export + admin-configurable offsite destinations
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>
This commit is contained in:
109
src/lib/services/backup-destinations/bundle-encryption.ts
Normal file
109
src/lib/services/backup-destinations/bundle-encryption.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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
|
||||
* `<name>.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<Buffer> {
|
||||
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<void> {
|
||||
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<void>((resolve) => out.once('drain', () => resolve()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
out.write(cipher.getAuthTag());
|
||||
await new Promise<void>((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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user