From e2a7f00a0730bfeed758ed50c60a9f208c2e0461 Mon Sep 17 00:00:00 2001 From: Simplereally Date: Sat, 17 Jan 2026 22:39:12 +1100 Subject: [PATCH 1/2] refactor: Replace Node.js Buffer operations with Web-standard APIs for Convex V8 runtime compatibility. --- convex/lib/crypto.ts | 112 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 94 insertions(+), 18 deletions(-) diff --git a/convex/lib/crypto.ts b/convex/lib/crypto.ts index 85c1da7..55cd430 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,82 @@ 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; +} + +/** + * Converts a Uint8Array to a proper ArrayBuffer. + * Used to satisfy TypeScript's strict BufferSource types for Web Crypto API. + * Creates a new ArrayBuffer (not a view) to avoid SharedArrayBuffer type issues. + */ +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + // Create a new ArrayBuffer and copy the data + // This ensures we get a proper ArrayBuffer type (not ArrayBuffer | SharedArrayBuffer) + const buffer = new ArrayBuffer(bytes.length); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +// 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 +118,12 @@ 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); cachedKey = await crypto.subtle.importKey( "raw", - keyData, + toArrayBuffer(keyData), { name: ALGORITHM }, false, // not extractable ["encrypt", "decrypt"], @@ -75,21 +151,21 @@ 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 const ciphertext = await crypto.subtle.encrypt( - { name: ALGORITHM, iv }, + { name: ALGORITHM, iv: toArrayBuffer(iv) }, 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 +183,21 @@ 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 const decrypted = await crypto.subtle.decrypt( - { name: ALGORITHM, iv }, + { name: ALGORITHM, iv: toArrayBuffer(iv) }, key, - new Uint8Array(encrypted), // explicit Uint8Array for Web Crypto compatibility + toArrayBuffer(encrypted), ); - // Decode as UTF-8 - return Buffer.from(decrypted).toString("utf8"); + // Decode as UTF-8 using TextDecoder + return textDecoder.decode(decrypted); } From 5830e6390381331b73b3845adb4008d8406a1005 Mon Sep 17 00:00:00 2001 From: Simplereally Date: Sat, 17 Jan 2026 22:44:48 +1100 Subject: [PATCH 2/2] fix: crypto tests failing --- convex/lib/crypto.ts | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/convex/lib/crypto.ts b/convex/lib/crypto.ts index 55cd430..2260234 100644 --- a/convex/lib/crypto.ts +++ b/convex/lib/crypto.ts @@ -72,19 +72,6 @@ function concatBytes(...arrays: Uint8Array[]): Uint8Array { return result; } -/** - * Converts a Uint8Array to a proper ArrayBuffer. - * Used to satisfy TypeScript's strict BufferSource types for Web Crypto API. - * Creates a new ArrayBuffer (not a view) to avoid SharedArrayBuffer type issues. - */ -function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { - // Create a new ArrayBuffer and copy the data - // This ensures we get a proper ArrayBuffer type (not ArrayBuffer | SharedArrayBuffer) - const buffer = new ArrayBuffer(bytes.length); - new Uint8Array(buffer).set(bytes); - return buffer; -} - // TextEncoder/TextDecoder for UTF-8 string conversion const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -121,9 +108,11 @@ async function getEncryptionKey(): Promise { // 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", - toArrayBuffer(keyData), + keyData as BufferSource, { name: ALGORITHM }, false, // not extractable ["encrypt", "decrypt"], @@ -155,8 +144,9 @@ export async function encryptApiKey(apiKey: string): Promise { const data = textEncoder.encode(apiKey); // Encrypt (Web Crypto API includes auth tag in the ciphertext) + // Type assertion needed for Convex's strict TypeScript config (iv is BufferSource) const ciphertext = await crypto.subtle.encrypt( - { name: ALGORITHM, iv: toArrayBuffer(iv) }, + { name: ALGORITHM, iv: iv as BufferSource }, key, data, ); @@ -191,10 +181,11 @@ export async function decryptApiKey(ciphertext: string): Promise { 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: toArrayBuffer(iv) }, + { name: ALGORITHM, iv: iv as BufferSource }, key, - toArrayBuffer(encrypted), + encrypted as BufferSource, ); // Decode as UTF-8 using TextDecoder