import "server-only"; import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto"; /** * Application-level encryption for user provider API keys. * * Envelope format (all base64url): v1... * Cipher: AES-256-GCM. The data key is derived from API_KEY_ENCRYPTION_SECRET with HKDF-SHA256 * (info = "polyllm:provider-key:v1") so the raw secret never touches the cipher directly and * we can introduce v2 envelopes with a different derivation later. * * The AAD binds the ciphertext to the user id + provider so a row copied to another user * cannot be decrypted. */ const VERSION = "v1"; const INFO = "polyllm:provider-key:v1"; let cachedKey: Buffer | null = null; function dataKey(): Buffer { if (cachedKey) return cachedKey; const secret = process.env.API_KEY_ENCRYPTION_SECRET; if (!secret || secret.length < 32) { if (process.env.NODE_ENV === "production") { throw new Error("API_KEY_ENCRYPTION_SECRET must be set (32+ characters)"); } cachedKey = Buffer.from(hkdfSync("sha256", "dev-only-insecure-secret-polyllm", "", INFO, 32)); return cachedKey; } cachedKey = Buffer.from(hkdfSync("sha256", secret, "", INFO, 32)); return cachedKey; } function b64u(buf: Buffer): string { return buf.toString("base64url"); } function fromB64u(s: string): Buffer { return Buffer.from(s, "base64url"); } export interface KeyContext { userId: string; provider: string; } export function encryptSecret(plaintext: string, ctx: KeyContext): string { const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", dataKey(), iv); cipher.setAAD(Buffer.from(`${ctx.userId}|${ctx.provider}`, "utf8")); const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return [VERSION, b64u(iv), b64u(tag), b64u(ct)].join("."); } export function decryptSecret(envelope: string, ctx: KeyContext): string { const [version, ivS, tagS, ctS] = envelope.split("."); if (version !== VERSION || !ivS || !tagS || !ctS) { throw new Error("Unsupported key envelope"); } const decipher = createDecipheriv("aes-256-gcm", dataKey(), fromB64u(ivS)); decipher.setAAD(Buffer.from(`${ctx.userId}|${ctx.provider}`, "utf8")); decipher.setAuthTag(fromB64u(tagS)); const pt = Buffer.concat([decipher.update(fromB64u(ctS)), decipher.final()]); return pt.toString("utf8"); } /** Deterministic fingerprint (SHA-256) — for "same key" detection and audit, never reversible. */ export function fingerprintSecret(plaintext: string): string { return createHash("sha256").update(plaintext, "utf8").digest("hex"); } /** `sk-••••••••9A2K` — keeps the recognizable prefix and the last 4 chars only. */ export function keyHint(plaintext: string): string { const trimmed = plaintext.trim(); const last4 = trimmed.slice(-4); const prefixMatch = trimmed.match(/^([A-Za-z]+-(?:[a-z]+-)?)/); const prefix = prefixMatch ? prefixMatch[1].slice(0, 8) : ""; return `${prefix}••••••••${last4}`; } export function constantTimeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); if (ab.length !== bb.length) return false; return timingSafeEqual(ab, bb); } /** Redact anything that looks like a provider key from free text (logs, error messages). */ export function redactSecrets(text: string): string { return text .replace(/sk-ant-[A-Za-z0-9_-]{20,}/g, "sk-ant-•••") .replace(/sk-proj-[A-Za-z0-9_-]{20,}/g, "sk-proj-•••") .replace(/sk-[A-Za-z0-9_-]{20,}/g, "sk-•••") .replace(/xai-[A-Za-z0-9_-]{20,}/g, "xai-•••") .replace(/AIza[0-9A-Za-z_-]{30,}/g, "AIza•••") .replace(/AQ\.[A-Za-z0-9_-]{30,}/g, "AQ.•••") .replace(/re_[A-Za-z0-9_-]{20,}/g, "re_•••") .replace(/Bearer\s+[A-Za-z0-9._-]{16,}/gi, "Bearer •••"); }