crypto.ts
2,485 bytes
| 1 | /** |
|---|---|
| 2 | * AES-256-GCM encryption for user-saved OpenRouter API keys. |
| 3 | * |
| 4 | * Keys are stored encrypted at rest with a server-side master key from |
| 5 | * `ENCRYPTION_KEY`. GCM gives us confidentiality + integrity (the auth tag |
| 6 | * detects tampering). Each ciphertext carries a fresh random 96-bit IV, so |
| 7 | * encrypting the same key twice yields different ciphertexts. |
| 8 | * |
| 9 | * Wire format (all base64url, dot-separated, versioned): |
| 10 | * v1.<iv>.<authTag>.<ciphertext> |
| 11 | */ |
| 12 | import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto'; |
| 13 | import { env } from './env'; |
| 14 | |
| 15 | const ALGORITHM = 'aes-256-gcm'; |
| 16 | const IV_BYTES = 12; |
| 17 | const VERSION = 'v1'; |
| 18 | |
| 19 | function masterKey(): Buffer { |
| 20 | const raw = env.ENCRYPTION_KEY; |
| 21 | const key = /^[0-9a-fA-F]{64}$/.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64'); |
| 22 | if (key.length !== 32) { |
| 23 | throw new Error('ENCRYPTION_KEY must decode to 32 bytes for AES-256-GCM.'); |
| 24 | } |
| 25 | return key; |
| 26 | } |
| 27 | |
| 28 | export function encryptSecret(plaintext: string): string { |
| 29 | const iv = randomBytes(IV_BYTES); |
| 30 | const cipher = createCipheriv(ALGORITHM, masterKey(), iv); |
| 31 | const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); |
| 32 | const authTag = cipher.getAuthTag(); |
| 33 | return [VERSION, b64url(iv), b64url(authTag), b64url(ciphertext)].join('.'); |
| 34 | } |
| 35 | |
| 36 | export function decryptSecret(payload: string): string { |
| 37 | const parts = payload.split('.'); |
| 38 | if (parts.length !== 4 || parts[0] !== VERSION) { |
| 39 | throw new Error('Malformed or unsupported ciphertext.'); |
| 40 | } |
| 41 | const [, ivB64, tagB64, ctB64] = parts; |
| 42 | const decipher = createDecipheriv(ALGORITHM, masterKey(), fromB64url(ivB64!)); |
| 43 | decipher.setAuthTag(fromB64url(tagB64!)); |
| 44 | const plaintext = Buffer.concat([decipher.update(fromB64url(ctB64!)), decipher.final()]); |
| 45 | return plaintext.toString('utf8'); |
| 46 | } |
| 47 | |
| 48 | /** Constant-time equality for comparing secrets/tokens without leaking length. */ |
| 49 | export function safeEqual(a: string, b: string): boolean { |
| 50 | const ba = Buffer.from(a); |
| 51 | const bb = Buffer.from(b); |
| 52 | if (ba.length !== bb.length) return false; |
| 53 | return timingSafeEqual(ba, bb); |
| 54 | } |
| 55 | |
| 56 | /** A short, non-reversible fingerprint for display ("sk-or-...a1b2"). */ |
| 57 | export function maskKey(key: string): string { |
| 58 | if (key.length <= 8) return '••••'; |
| 59 | return `${key.slice(0, 6)}...${key.slice(-4)}`; |
| 60 | } |
| 61 | |
| 62 | function b64url(buf: Buffer): string { |
| 63 | return buf.toString('base64url'); |
| 64 | } |
| 65 | function fromB64url(s: string): Buffer { |
| 66 | return Buffer.from(s, 'base64url'); |
| 67 | } |
| 68 | |