profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
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