/** * AES-256-GCM decryption using Web Crypto API. * * Decrypts data encrypted by Django-CFG backend. */ import type { DecryptionConfig, DecryptionError, DecryptionResult, EncryptedField, EncryptedResponse, } from './types'; import { deriveKeyFromConfig } from './key-derivation'; import { isEncryptedField, isEncryptedResponse } from './types'; /** * Decode base64 string to Uint8Array. */ 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; } /** * Decrypt AES-256-GCM ciphertext. * * @param ciphertext - Encrypted data bytes * @param key - CryptoKey for decryption * @param iv - Initialization vector * @param authTag - Authentication tag * @returns Promise resolving to decrypted bytes */ export async function decryptAES256GCM( ciphertext: Uint8Array, key: CryptoKey, iv: Uint8Array, authTag: Uint8Array ): Promise { // GCM expects ciphertext + authTag concatenated const combined = new Uint8Array(ciphertext.length + authTag.length); combined.set(ciphertext); combined.set(authTag, ciphertext.length); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv.buffer as ArrayBuffer, tagLength: 128, // 16 bytes = 128 bits }, key, combined ); return new Uint8Array(decrypted); } /** * Decrypt a single encrypted field value. * * @param field - Encrypted field envelope * @param key - CryptoKey for decryption * @returns Promise resolving to decrypted value * * @example * ```typescript * const key = await deriveKeyFromConfig({ secretKey: '...' }); * const price = await decryptField(response.price, key); * console.log(price); // 99.99 * ``` */ export async function decryptField( field: EncryptedField, key: CryptoKey ): Promise { if (field.algorithm !== 'AES-256-GCM') { throw new Error(`Unsupported algorithm: ${field.algorithm}`); } const iv = base64ToBytes(field.iv); const ciphertext = base64ToBytes(field.data); const authTag = base64ToBytes(field.auth_tag); const decrypted = await decryptAES256GCM(ciphertext, key, iv, authTag); const text = new TextDecoder().decode(decrypted); return JSON.parse(text) as T; } /** * Decrypt an entire encrypted response. * * @param response - Encrypted response envelope * @param secretKey - Secret key for key derivation * @param config - Additional config (userId, sessionId, etc.) * @returns Promise resolving to decrypted response data */ export async function decryptResponse( response: EncryptedResponse, secretKey: string, config?: Partial> ): Promise { if (response.algorithm !== 'AES-256-GCM') { throw new Error(`Unsupported algorithm: ${response.algorithm}`); } // Use salt from response for key derivation const salt = base64ToBytes(response.salt); const iterations = config?.iterations ?? 100000; // Import secret key for PBKDF2 const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( 'raw', encoder.encode(secretKey), 'PBKDF2', false, ['deriveKey'] ); // Derive decryption key const key = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: salt.buffer as ArrayBuffer, iterations: iterations, hash: 'SHA-256', }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['decrypt'] ); const iv = base64ToBytes(response.iv); const ciphertext = base64ToBytes(response.data); const authTag = base64ToBytes(response.auth_tag); const decrypted = await decryptAES256GCM(ciphertext, key, iv, authTag); const text = new TextDecoder().decode(decrypted); return JSON.parse(text) as T; } /** * Recursively decrypt all encrypted fields in an object. * * @param data - Object potentially containing encrypted fields * @param key - CryptoKey for decryption * @returns Promise resolving to object with all fields decrypted * * @example * ```typescript * const key = await deriveKeyFromConfig({ secretKey: '...' }); * const product = await decryptObject(response, key); * // product.price is now decrypted * ``` */ export async function decryptObject( data: unknown, key: CryptoKey ): Promise { if (data === null || data === undefined) { return data as T; } // Check if this is an encrypted field if (isEncryptedField(data)) { return decryptField(data, key); } // Handle arrays if (Array.isArray(data)) { const decrypted = await Promise.all( data.map((item) => decryptObject(item, key)) ); return decrypted as T; } // Handle objects if (typeof data === 'object') { const result: Record = {}; const entries = Object.entries(data as Record); for (const [objKey, value] of entries) { result[objKey] = await decryptObject(value, key); } return result as T; } // Primitive values pass through return data as T; } /** * Create a decryption client with pre-configured key. * * @param config - Decryption configuration * @returns Object with decryption methods * * @example * ```typescript * const crypto = await createDecryptionClient({ * secretKey: 'django-secret-key', * userId: currentUser.id * }); * * const response = await fetch('/api/products/?encrypt=true'); * const data = await crypto.decryptObject(await response.json()); * ``` */ export async function createDecryptionClient(config: DecryptionConfig) { const key = await deriveKeyFromConfig(config); return { /** * Decrypt a single encrypted field. */ decryptField: (field: EncryptedField) => decryptField(field, key), /** * Recursively decrypt all encrypted fields in an object. */ decryptObject: (data: unknown) => decryptObject(data, key), /** * Check if a value is an encrypted field. */ isEncryptedField, /** * Check if a value is an encrypted response. */ isEncryptedResponse, }; } /** * Safe decryption wrapper that returns result or error. * * @param fn - Async function to execute * @returns Promise resolving to DecryptionResult or DecryptionError */ export async function safeDecrypt( fn: () => Promise ): Promise | DecryptionError> { try { const data = await fn(); return { success: true, data }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; // Determine error code let code: DecryptionError['code'] = 'DECRYPTION_FAILED'; if (message.includes('format') || message.includes('parse')) { code = 'INVALID_FORMAT'; } else if (message.includes('auth') || message.includes('tag')) { code = 'AUTH_FAILED'; } else if (message.includes('key')) { code = 'KEY_ERROR'; } return { success: false, message, code }; } }