diff --git a/convex/lib/crypto.ts b/convex/lib/crypto.ts index 85c1da7..2260234 100644 --- a/convex/lib/crypto.ts +++ b/convex/lib/crypto.ts @@ -4,6 +4,9 @@ * This module provides AES-256-GCM encryption for storing API keys securely. * Uses the Web Crypto API (SubtleCrypto) which is available in the Convex runtime. * + * IMPORTANT: This module runs in the Convex V8 isolate runtime, NOT Node.js. + * Therefore, we must use Web-standard APIs only (no Node.js Buffer). + * * Requires ENCRYPTION_KEY environment variable to be set in Convex. */ @@ -14,8 +17,69 @@ const ALGORITHM = "AES-GCM"; const IV_LENGTH = 12; // ============================================================ -// Helper Functions +// Web-Standard Helper Functions (no Node.js Buffer) +// ============================================================ + +/** + * Converts a hex string to a Uint8Array. + * Web-standard replacement for Buffer.from(hex, "hex"). + */ +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Converts a Uint8Array to a base64 string. + * Web-standard replacement for Buffer.toString("base64"). + */ +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Converts a base64 string to a Uint8Array. + * Web-standard replacement for Buffer.from(base64, "base64"). + */ +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Concatenates multiple Uint8Arrays into one. + * Web-standard replacement for Buffer.concat(). + */ +function concatBytes(...arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +// TextEncoder/TextDecoder for UTF-8 string conversion +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +// ============================================================ +// Encryption Key Management // ============================================================ + /** * Imports and memoizes the encryption key from the environment variable. * The key is parsed once and cached for subsequent calls to improve performance. @@ -41,13 +105,14 @@ async function getEncryptionKey(): Promise { ); } - // Convert to Uint8Array to ensure compatibility with Web Crypto API types - // (avoiding explicit 'as unknown as BufferSource' casts) - const keyData = new Uint8Array(Buffer.from(encryptionKey, "hex")); + // Convert hex string to Uint8Array using Web-standard helper + const keyData = hexToBytes(encryptionKey); + // Note: Uint8Array is a valid BufferSource for Web Crypto API + // Type assertion needed for Convex's strict TypeScript config cachedKey = await crypto.subtle.importKey( "raw", - keyData, + keyData as BufferSource, { name: ALGORITHM }, false, // not extractable ["encrypt", "decrypt"], @@ -75,21 +140,22 @@ export async function encryptApiKey(apiKey: string): Promise { const iv = new Uint8Array(IV_LENGTH); crypto.getRandomValues(iv); - // Encode the API key as UTF-8 - const data = Buffer.from(apiKey, "utf8"); + // Encode the API key as UTF-8 using TextEncoder + const data = textEncoder.encode(apiKey); // Encrypt (Web Crypto API includes auth tag in the ciphertext) - // crypto.subtle.encrypt returns an ArrayBuffer + // Type assertion needed for Convex's strict TypeScript config (iv is BufferSource) const ciphertext = await crypto.subtle.encrypt( - { name: ALGORITHM, iv }, + { name: ALGORITHM, iv: iv as BufferSource }, key, data, ); // Combine IV + ciphertext (which includes auth tag) - const combined = Buffer.concat([iv, Buffer.from(ciphertext)]); + const combined = concatBytes(iv, new Uint8Array(ciphertext)); - return combined.toString("base64"); + // Convert to base64 for storage + return bytesToBase64(combined); } // ============================================================ @@ -107,21 +173,22 @@ export async function encryptApiKey(apiKey: string): Promise { export async function decryptApiKey(ciphertext: string): Promise { const key = await getEncryptionKey(); - const combined = Buffer.from(ciphertext, "base64"); + // Decode base64 to bytes + const combined = base64ToBytes(ciphertext); - // Extract IV and ciphertext (which includes auth tag) - // subarray shares memory, similar to slice on TypedArray + // Extract IV and encrypted data (which includes auth tag) const iv = combined.subarray(0, IV_LENGTH); const encrypted = combined.subarray(IV_LENGTH); // Decrypt + // Type assertions needed for Convex's strict TypeScript config const decrypted = await crypto.subtle.decrypt( - { name: ALGORITHM, iv }, + { name: ALGORITHM, iv: iv as BufferSource }, key, - new Uint8Array(encrypted), // explicit Uint8Array for Web Crypto compatibility + encrypted as BufferSource, ); - // Decode as UTF-8 - return Buffer.from(decrypted).toString("utf8"); + // Decode as UTF-8 using TextDecoder + return textDecoder.decode(decrypted); }