53 lines
1.8 KiB
TypeScript
53 lines
1.8 KiB
TypeScript
/**
|
|
* 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<CryptoKey> {
|
|
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<string> {
|
|
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<string> {
|
|
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));
|
|
}
|