TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import "server-only";2import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";34/**5 * Application-level encryption for user provider API keys.6 *7 * Envelope format (all base64url): v1.<iv 12B>.<authTag 16B>.<ciphertext>8 * Cipher: AES-256-GCM. The data key is derived from API_KEY_ENCRYPTION_SECRET with HKDF-SHA2569 * (info = "polyllm:provider-key:v1") so the raw secret never touches the cipher directly and10 * we can introduce v2 envelopes with a different derivation later.11 *12 * The AAD binds the ciphertext to the user id + provider so a row copied to another user13 * cannot be decrypted.14 */1516const VERSION = "v1";17const INFO = "polyllm:provider-key:v1";1819let cachedKey: Buffer | null = null;2021function dataKey(): Buffer {22 if (cachedKey) return cachedKey;23 const secret = process.env.API_KEY_ENCRYPTION_SECRET;24 if (!secret || secret.length < 32) {25 if (process.env.NODE_ENV === "production") {26 throw new Error("API_KEY_ENCRYPTION_SECRET must be set (32+ characters)");27 }28 cachedKey = Buffer.from(hkdfSync("sha256", "dev-only-insecure-secret-polyllm", "", INFO, 32));29 return cachedKey;30 }31 cachedKey = Buffer.from(hkdfSync("sha256", secret, "", INFO, 32));32 return cachedKey;33}3435function b64u(buf: Buffer): string {36 return buf.toString("base64url");37}38function fromB64u(s: string): Buffer {39 return Buffer.from(s, "base64url");40}4142export interface KeyContext {43 userId: string;44 provider: string;45}4647export function encryptSecret(plaintext: string, ctx: KeyContext): string {48 const iv = randomBytes(12);49 const cipher = createCipheriv("aes-256-gcm", dataKey(), iv);50 cipher.setAAD(Buffer.from(`${ctx.userId}|${ctx.provider}`, "utf8"));51 const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);52 const tag = cipher.getAuthTag();53 return [VERSION, b64u(iv), b64u(tag), b64u(ct)].join(".");54}5556export function decryptSecret(envelope: string, ctx: KeyContext): string {57 const [version, ivS, tagS, ctS] = envelope.split(".");58 if (version !== VERSION || !ivS || !tagS || !ctS) {59 throw new Error("Unsupported key envelope");60 }61 const decipher = createDecipheriv("aes-256-gcm", dataKey(), fromB64u(ivS));62 decipher.setAAD(Buffer.from(`${ctx.userId}|${ctx.provider}`, "utf8"));63 decipher.setAuthTag(fromB64u(tagS));64 const pt = Buffer.concat([decipher.update(fromB64u(ctS)), decipher.final()]);65 return pt.toString("utf8");66}6768/** Deterministic fingerprint (SHA-256) — for "same key" detection and audit, never reversible. */69export function fingerprintSecret(plaintext: string): string {70 return createHash("sha256").update(plaintext, "utf8").digest("hex");71}7273/** `sk-••••••••9A2K` — keeps the recognizable prefix and the last 4 chars only. */74export function keyHint(plaintext: string): string {75 const trimmed = plaintext.trim();76 const last4 = trimmed.slice(-4);77 const prefixMatch = trimmed.match(/^([A-Za-z]+-(?:[a-z]+-)?)/);78 const prefix = prefixMatch ? prefixMatch[1].slice(0, 8) : "";79 return `${prefix}••••••••${last4}`;80}8182export function constantTimeEqual(a: string, b: string): boolean {83 const ab = Buffer.from(a);84 const bb = Buffer.from(b);85 if (ab.length !== bb.length) return false;86 return timingSafeEqual(ab, bb);87}8889/** Redact anything that looks like a provider key from free text (logs, error messages). */90export function redactSecrets(text: string): string {91 return text92 .replace(/sk-ant-[A-Za-z0-9_-]{20,}/g, "sk-ant-•••")93 .replace(/sk-proj-[A-Za-z0-9_-]{20,}/g, "sk-proj-•••")94 .replace(/sk-[A-Za-z0-9_-]{20,}/g, "sk-•••")95 .replace(/xai-[A-Za-z0-9_-]{20,}/g, "xai-•••")96 .replace(/AIza[0-9A-Za-z_-]{30,}/g, "AIza•••")97 .replace(/AQ\.[A-Za-z0-9_-]{30,}/g, "AQ.•••")98 .replace(/re_[A-Za-z0-9_-]{20,}/g, "re_•••")99 .replace(/Bearer\s+[A-Za-z0-9._-]{16,}/gi, "Bearer •••");100}101