// src/convex/lib/encryption.ts
import { webcrypto } from 'crypto';
const MASTER_KEY = process.env.MASTER_ENCRYPTION_KEY!;
if (!MASTER_KEY || MASTER_KEY.length < 32) {
throw new Error('MASTER_ENCRYPTION_KEY must be at least 32 characters');
}
/**
* Encrypt sensitive data using AES-256-GCM
*
* @param data - Plain text data to encrypt
* @param salt - Unique salt (typically userId or projectId)
* @returns Base64-encoded encrypted data (IV + ciphertext + auth tag)
*/
export async function encrypt(data: string, salt: string): Promise<string> {
const encoder = new TextEncoder();
// Derive encryption key from master key + salt
const keyMaterial = await webcrypto.subtle.importKey(
'raw',
encoder.encode(MASTER_KEY + salt),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
const key = await webcrypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(salt),
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
// Generate random IV (12 bytes for GCM)
const iv = webcrypto.getRandomValues(new Uint8Array(12));
// Encrypt data
const encrypted = await webcrypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoder.encode(data)
);
// Combine IV + encrypted data
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
// Return as base64
return Buffer.from(combined).toString('base64');
}
/**
* Decrypt data encrypted with encrypt()
*
* @param encryptedData - Base64-encoded encrypted data
* @param salt - Same salt used for encryption
* @returns Decrypted plain text
*/
export async function decrypt(encryptedData: string, salt: string): Promise<string> {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// Decode from base64
const combined = Buffer.from(encryptedData, 'base64');
// Extract IV (first 12 bytes)
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
// Derive same encryption key
const keyMaterial = await webcrypto.subtle.importKey(
'raw',
encoder.encode(MASTER_KEY + salt),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
const key = await webcrypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(salt),
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// Decrypt data
const decrypted = await webcrypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
encrypted
);
return decoder.decode(decrypted);
}
/**
* Hash data using SHA-256 (for API keys)
*
* @param data - Data to hash
* @returns Hex-encoded hash
*/
export async function hash(data: string): Promise<string> {
const encoder = new TextEncoder();
const hashBuffer = await webcrypto.subtle.digest(
'SHA-256',
encoder.encode(data)
);
return Buffer.from(hashBuffer).toString('hex');
}