/** * BYOEH Client-Side Encryption Service * * Provides AES-256-GCM encryption for Erwartungshorizonte. * The passphrase NEVER leaves the browser - only the encrypted data * and a hash of the derived key are sent to the server. * * Security Flow: * 1. User enters passphrase * 2. PBKDF2 derives a 256-bit key from passphrase + random salt * 3. AES-256-GCM encrypts the file content * 4. SHA-256 hash of derived key is created for server-side verification * 5. Encrypted blob + key hash + salt are uploaded (NOT the passphrase!) */ export interface EncryptionResult { encryptedData: ArrayBuffer keyHash: string salt: string iv: string } export interface DecryptionResult { decryptedData: ArrayBuffer success: boolean error?: string } /** * Convert ArrayBuffer to hex string */ function bufferToHex(buffer: Uint8Array): string { return Array.from(buffer) .map(b => b.toString(16).padStart(2, '0')) .join('') } /** * Convert hex string to ArrayBuffer */ function hexToBuffer(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2) for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substr(i, 2), 16) } return bytes } /** * Derive an AES-256 key from passphrase using PBKDF2 */ async function deriveKey( passphrase: string, salt: Uint8Array ): Promise { // Import passphrase as key material const keyMaterial = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey'] ) // Derive AES-256-GCM key using PBKDF2 return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: salt.buffer as ArrayBuffer, iterations: 100000, // High iteration count for security hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, true, // extractable for hashing ['encrypt', 'decrypt'] ) } /** * Create SHA-256 hash of the derived key for server verification */ async function hashKey(key: CryptoKey): Promise { const rawKey = await crypto.subtle.exportKey('raw', key) const hashBuffer = await crypto.subtle.digest('SHA-256', rawKey) return bufferToHex(new Uint8Array(hashBuffer)) } /** * Encrypt a file using AES-256-GCM * * @param file - File to encrypt * @param passphrase - User's passphrase (never sent to server) * @returns Encrypted data + metadata for upload */ export async function encryptFile( file: File, passphrase: string ): Promise { // 1. Generate random 16-byte salt const salt = crypto.getRandomValues(new Uint8Array(16)) // 2. Derive key from passphrase const key = await deriveKey(passphrase, salt) // 3. Generate random 12-byte IV (required for AES-GCM) const iv = crypto.getRandomValues(new Uint8Array(12)) // 4. Read file content const fileBuffer = await file.arrayBuffer() // 5. Encrypt using AES-256-GCM const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, fileBuffer ) // 6. Create key hash for server-side verification const keyHash = await hashKey(key) // 7. Combine IV + ciphertext (IV is needed for decryption) const combined = new Uint8Array(iv.length + encrypted.byteLength) combined.set(iv, 0) combined.set(new Uint8Array(encrypted), iv.length) return { encryptedData: combined.buffer, keyHash, salt: bufferToHex(salt), iv: bufferToHex(iv) } } /** * Encrypt text content using AES-256-GCM * * @param text - Text to encrypt * @param passphrase - User's passphrase * @param saltHex - Salt as hex string * @returns Base64-encoded encrypted content */ export async function encryptText( text: string, passphrase: string, saltHex: string ): Promise { const salt = hexToBuffer(saltHex) const key = await deriveKey(passphrase, salt) const iv = crypto.getRandomValues(new Uint8Array(12)) const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, new TextEncoder().encode(text) ) // Combine IV + ciphertext const combined = new Uint8Array(iv.length + encrypted.byteLength) combined.set(iv, 0) combined.set(new Uint8Array(encrypted), iv.length) // Return as base64 return btoa(String.fromCharCode(...combined)) } /** * Decrypt text content using AES-256-GCM * * @param encryptedBase64 - Base64-encoded encrypted content (IV + ciphertext) * @param passphrase - User's passphrase * @param saltHex - Salt as hex string * @returns Decrypted text */ export async function decryptText( encryptedBase64: string, passphrase: string, saltHex: string ): Promise { const salt = hexToBuffer(saltHex) const key = await deriveKey(passphrase, salt) // Decode base64 const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0)) // Extract IV (first 12 bytes) and ciphertext const iv = combined.slice(0, 12) const ciphertext = combined.slice(12) // Decrypt const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, ciphertext ) return new TextDecoder().decode(decrypted) } /** * Decrypt a file using AES-256-GCM * * @param encryptedData - Encrypted data (IV + ciphertext) * @param passphrase - User's passphrase * @param saltHex - Salt as hex string * @returns Decrypted file content */ export async function decryptFile( encryptedData: ArrayBuffer, passphrase: string, saltHex: string ): Promise { try { const salt = hexToBuffer(saltHex) const key = await deriveKey(passphrase, salt) const combined = new Uint8Array(encryptedData) // Extract IV (first 12 bytes) and ciphertext const iv = combined.slice(0, 12) const ciphertext = combined.slice(12) // Decrypt const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, ciphertext ) return { decryptedData: decrypted, success: true } } catch (error) { return { decryptedData: new ArrayBuffer(0), success: false, error: error instanceof Error ? error.message : 'Decryption failed' } } } /** * Verify a passphrase against stored key hash * * @param passphrase - Passphrase to verify * @param saltHex - Salt as hex string * @param expectedHash - Expected key hash * @returns true if passphrase is correct */ export async function verifyPassphrase( passphrase: string, saltHex: string, expectedHash: string ): Promise { try { const salt = hexToBuffer(saltHex) const key = await deriveKey(passphrase, salt) const computedHash = await hashKey(key) return computedHash === expectedHash } catch { return false } } /** * Generate a key hash for a given passphrase and salt * Used when creating a new encrypted document * * @param passphrase - User's passphrase * @param saltHex - Salt as hex string * @returns Key hash for storage */ export async function generateKeyHash( passphrase: string, saltHex: string ): Promise { const salt = hexToBuffer(saltHex) const key = await deriveKey(passphrase, salt) return hashKey(key) } /** * Generate a random salt for encryption * * @returns 16-byte salt as hex string */ export function generateSalt(): string { const salt = crypto.getRandomValues(new Uint8Array(16)) return bufferToHex(salt) } /** * Check if Web Crypto API is available */ export function isEncryptionSupported(): boolean { return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined' }