/** * convex/lib/crypto.ts * * Symmetrische Ver-/Entschlüsselung der hinterlegten Kunden-Secrets * (BYO-API-Keys, SMTP-Passwörter). Nutzt Web Crypto (AES-GCM). * * WICHTIG: Nur aus Convex *Actions* aufrufen — encrypt() braucht * Zufalls-IV (nicht-deterministisch) und ist daher in Mutations/Queries * nicht erlaubt. Der Master-Key kommt aus der Umgebung (niemals ins Repo). * * Env: SECRET_ENCRYPTION_KEY = base64-kodierter 32-Byte-Schlüssel. */ function getKeyMaterial(): Promise { const b64 = process.env.SECRET_ENCRYPTION_KEY; if (!b64) throw new Error("SECRET_ENCRYPTION_KEY ist nicht gesetzt"); const raw = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); return crypto.subtle.importKey("raw", raw, "AES-GCM", false, [ "encrypt", "decrypt", ]); } /** Klartext → "ivBase64:cipherBase64". */ export async function encryptSecret(plaintext: string): Promise { const key = await getKeyMaterial(); const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = new TextEncoder().encode(plaintext); const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc); return `${toB64(iv)}:${toB64(new Uint8Array(cipher))}`; } /** "ivBase64:cipherBase64" → Klartext. */ export async function decryptSecret(stored: string): Promise { const key = await getKeyMaterial(); const [ivB64, cipherB64] = stored.split(":"); const iv = fromB64(ivB64); const cipher = fromB64(cipherB64); const plain = await crypto.subtle.decrypt( { name: "AES-GCM", iv }, key, cipher, ); return new TextDecoder().decode(plain); } function toB64(bytes: Uint8Array): string { return btoa(String.fromCharCode(...bytes)); } function fromB64(b64: string): Uint8Array { return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); }