import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual, } from 'node:crypto'; const ALGORITHM = 'aes-256-gcm'; const IV_BYTES = 12; const KEY_BYTES = 32; const loadKey = (): Buffer => { const raw = process.env.TOKEN_ENCRYPTION_KEY; if (!raw) { throw new Error('TOKEN_ENCRYPTION_KEY is not set'); } const key = Buffer.from(raw, 'base64'); if (key.length !== KEY_BYTES) { throw new Error( `TOKEN_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes, got ${key.length}` ); } return key; }; /** Serialized as base64(iv):base64(authTag):base64(ciphertext). */ export const encryptSecret = (plaintext: string): string => { const iv = randomBytes(IV_BYTES); const cipher = createCipheriv(ALGORITHM, loadKey(), iv); const payload = Buffer.concat([ cipher.update(plaintext, 'utf8'), cipher.final(), ]); return [ iv.toString('base64'), cipher.getAuthTag().toString('base64'), payload.toString('base64'), ].join(':'); }; export const decryptSecret = (ciphertext: string): string => { const parts = ciphertext.split(':'); if (parts.length !== 3) { throw new Error('Ciphertext is not in the expected iv:tag:payload form'); } const [iv, tag, payload] = parts; const decipher = createDecipheriv( ALGORITHM, loadKey(), Buffer.from(iv, 'base64') ); decipher.setAuthTag(Buffer.from(tag, 'base64')); return Buffer.concat([ decipher.update(Buffer.from(payload, 'base64')), decipher.final(), ]).toString('utf8'); }; export const hashApiKey = (key: string): string => createHash('sha256').update(key, 'utf8').digest('hex'); /** * Constant-time string comparison. Both sides are hashed first so that * unequal lengths cannot short-circuit the comparison or throw. */ export const safeEquals = (a: string, b: string): boolean => { const digestA = createHash('sha256').update(a, 'utf8').digest(); const digestB = createHash('sha256').update(b, 'utf8').digest(); return timingSafeEqual(digestA, digestB); };