SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
3.9 KB · 101 lines typescript
Raw Blame History
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