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.
This commit is contained in:
+3
-2
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user