From 96a1fb9ef60646694acf22673c0d3e85d7efb502 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Thu, 14 May 2026 17:49:39 -0700 Subject: [PATCH] fix: stop gitignore from matching lib/secrets/ application code The .gitignore rule `secrets/` matched any directory named secrets/ anywhere in the tree, including the new lib/secrets/ application code that holds the AES-GCM helpers. Production build failed with "Module not found: Can't resolve '@/lib/secrets/crypto'". Anchor the ignore to the repo root with `/secrets/` so only the top-level credentials directory is excluded. --- .gitignore | 5 ++-- lib/secrets/crypto.ts | 56 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 lib/secrets/crypto.ts diff --git a/.gitignore b/.gitignore index 4dae1ca..e863a72 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ playwright-report/ supabase/.branches/ supabase/.temp/ -# Production deployment secrets (never commit) -secrets/ +# Production deployment secrets (never commit). Anchored to repo root so it +# doesn't also match lib/secrets/ (which is application code, not credentials). +/secrets/ *.pem diff --git a/lib/secrets/crypto.ts b/lib/secrets/crypto.ts new file mode 100644 index 0000000..bc70d85 --- /dev/null +++ b/lib/secrets/crypto.ts @@ -0,0 +1,56 @@ +// AES-256-GCM encryption for the Secrets vault. +// +// Key handling: +// The master key comes from process.env.SECRETS_ENCRYPTION_KEY (64 hex chars). +// It MUST NOT be reachable from the browser; this module is server-only. +// +// Storage layout (encryption_version = 1): +// ciphertext field bytes = iv (12) || ciphertext (N) || authTag (16) +// +// A per-secret IV is generated on every encrypt. Decryption rejects any payload +// shorter than 28 bytes (12 IV + 16 tag) before touching the crypto primitive. + +import crypto from 'node:crypto' + +const VERSION = 1 +const IV_LEN = 12 +const TAG_LEN = 16 + +function readKey(): Buffer { + const hex = process.env.SECRETS_ENCRYPTION_KEY + if (!hex) { + throw new Error('SECRETS_ENCRYPTION_KEY is not set. Generate with `openssl rand -hex 32` and add to your environment.') + } + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error('SECRETS_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes).') + } + return Buffer.from(hex, 'hex') +} + +export function encryptionAvailable(): boolean { + const hex = process.env.SECRETS_ENCRYPTION_KEY + return !!hex && /^[0-9a-fA-F]{64}$/.test(hex) +} + +export function encryptSecret(plaintext: string): { ciphertext: Buffer; version: number } { + const key = readKey() + const iv = crypto.randomBytes(IV_LEN) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return { ciphertext: Buffer.concat([iv, ct, tag]), version: VERSION } +} + +export function decryptSecret(buf: Buffer | Uint8Array | null | undefined, version: number): string { + if (!buf) throw new Error('No ciphertext') + const data = Buffer.isBuffer(buf) ? buf : Buffer.from(buf) + if (version !== VERSION) throw new Error(`Unknown ciphertext version ${version}`) + if (data.length < IV_LEN + TAG_LEN + 1) throw new Error('Ciphertext too short') + const iv = data.subarray(0, IV_LEN) + const tag = data.subarray(data.length - TAG_LEN) + const ct = data.subarray(IV_LEN, data.length - TAG_LEN) + const key = readKey() + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8') +}