Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 85 additions & 18 deletions convex/lib/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand All @@ -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.
Expand All @@ -41,13 +105,14 @@ async function getEncryptionKey(): Promise<CryptoKey> {
);
}

// 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"],
Expand Down Expand Up @@ -75,21 +140,22 @@ export async function encryptApiKey(apiKey: string): Promise<string> {
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);
}

// ============================================================
Expand All @@ -107,21 +173,22 @@ export async function encryptApiKey(apiKey: string): Promise<string> {
export async function decryptApiKey(ciphertext: string): Promise<string> {
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);
}