/** * Voice Encryption - Client-Side AES-256-GCM * DSGVO-compliant: Encryption key NEVER leaves the device * * The master key is stored in IndexedDB (encrypted with device key) * Server only receives: * - Key hash (for verification) * - Encrypted blobs * - Namespace ID (pseudonym) * * NOTE: crypto.subtle is only available in secure contexts (HTTPS or localhost). * In development over HTTP (e.g., http://macmini:3000), encryption is disabled. */ /** * Check if we're in a secure context where crypto.subtle is available */ export function isSecureContext(): boolean { if (typeof window === 'undefined') return false if (typeof crypto === 'undefined') return false return crypto.subtle !== undefined } /** * Check if encryption is available * Returns false in non-secure HTTP contexts */ export function isEncryptionAvailable(): boolean { return isSecureContext() } const DB_NAME = 'breakpilot-voice' const DB_VERSION = 1 const STORE_NAME = 'keys' const MASTER_KEY_ID = 'master-key' /** * Open IndexedDB for key storage */ async function openDatabase(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION) request.onerror = () => reject(request.error) request.onsuccess = () => resolve(request.result) request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: 'id' }) } } }) } /** * Generate a new master key for encryption * This is called once when the teacher first uses voice features * Returns null if encryption is not available (non-secure context) */ export async function generateMasterKey(): Promise { if (!isEncryptionAvailable()) { console.warn('[VoiceEncryption] crypto.subtle nicht verfügbar - HTTP-Kontext erkannt. Verschlüsselung deaktiviert.') return null } const key = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, // extractable for storage ['encrypt', 'decrypt'] ) // Store in IndexedDB await storeMasterKey(key) return key } /** * Store master key in IndexedDB */ async function storeMasterKey(key: CryptoKey): Promise { if (!isEncryptionAvailable()) return const db = await openDatabase() const exportedKey = await crypto.subtle.exportKey('raw', key) return new Promise((resolve, reject) => { const transaction = db.transaction(STORE_NAME, 'readwrite') const store = transaction.objectStore(STORE_NAME) const request = store.put({ id: MASTER_KEY_ID, key: Array.from(new Uint8Array(exportedKey)), createdAt: new Date().toISOString(), }) request.onerror = () => reject(request.error) request.onsuccess = () => resolve() }) } /** * Get master key from IndexedDB * Returns null if encryption is not available */ export async function getMasterKey(): Promise { if (!isEncryptionAvailable()) { return null } try { const db = await openDatabase() return new Promise((resolve, reject) => { const transaction = db.transaction(STORE_NAME, 'readonly') const store = transaction.objectStore(STORE_NAME) const request = store.get(MASTER_KEY_ID) request.onerror = () => reject(request.error) request.onsuccess = async () => { const record = request.result if (!record) { resolve(null) return } // Import key const keyData = new Uint8Array(record.key) const key = await crypto.subtle.importKey( 'raw', keyData, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ) resolve(key) } }) } catch { return null } } /** * Generate key hash for server verification * Format: "sha256:base64encodedHash" * Returns "disabled" if encryption is not available */ export async function generateKeyHash(key: CryptoKey | null): Promise { if (!key || !isEncryptionAvailable()) { return 'disabled' } const exportedKey = await crypto.subtle.exportKey('raw', key) const hashBuffer = await crypto.subtle.digest('SHA-256', exportedKey) const hashArray = Array.from(new Uint8Array(hashBuffer)) const hashBase64 = btoa(String.fromCharCode(...hashArray)) return `sha256:${hashBase64}` } /** * Encrypt content before sending to server * @param content - Plaintext content * @param key - Master key * @returns Base64 encoded encrypted content */ export async function encryptContent( content: string, key: CryptoKey ): Promise { const iv = crypto.getRandomValues(new Uint8Array(12)) const encoded = new TextEncoder().encode(content) const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, encoded ) // Combine IV + ciphertext const combined = new Uint8Array(iv.length + ciphertext.byteLength) combined.set(iv) combined.set(new Uint8Array(ciphertext), iv.length) return btoa(String.fromCharCode(...combined)) } /** * Decrypt content received from server * @param encrypted - Base64 encoded encrypted content * @param key - Master key * @returns Decrypted plaintext */ export async function decryptContent( encrypted: string, key: CryptoKey ): Promise { const data = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0)) const iv = data.slice(0, 12) const ciphertext = data.slice(12) const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, ciphertext ) return new TextDecoder().decode(decrypted) } /** * Generate a namespace ID for the teacher * This is a pseudonym that doesn't contain PII */ export function generateNamespaceId(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)) const hex = Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join('') return `ns-${hex}` } /** * Get or create namespace ID */ export async function getNamespaceId(): Promise { const stored = localStorage.getItem('breakpilot-namespace-id') if (stored) { return stored } const newId = generateNamespaceId() localStorage.setItem('breakpilot-namespace-id', newId) return newId } /** * VoiceEncryption class for managing encryption state */ export class VoiceEncryption { private masterKey: CryptoKey | null = null private keyHash: string | null = null private namespaceId: string | null = null private encryptionEnabled: boolean = false /** * Initialize encryption * Creates master key if not exists * In non-secure contexts (HTTP), encryption is disabled but the class still works */ async initialize(): Promise { // Check if encryption is available this.encryptionEnabled = isEncryptionAvailable() if (!this.encryptionEnabled) { console.warn('[VoiceEncryption] Verschlüsselung deaktiviert - kein sicherer Kontext (HTTPS erforderlich)') console.warn('[VoiceEncryption] Für Produktion bitte HTTPS verwenden!') this.keyHash = 'disabled' this.namespaceId = await getNamespaceId() return } // Get or create master key this.masterKey = await getMasterKey() if (!this.masterKey) { this.masterKey = await generateMasterKey() } // Generate key hash this.keyHash = await generateKeyHash(this.masterKey) // Get namespace ID this.namespaceId = await getNamespaceId() } /** * Check if encryption is initialized */ isInitialized(): boolean { // Consider initialized even if encryption is disabled return this.keyHash !== null } /** * Check if encryption is actually enabled */ isEncryptionEnabled(): boolean { return this.encryptionEnabled && this.masterKey !== null } /** * Get key hash for server authentication */ getKeyHash(): string | null { return this.keyHash } /** * Get namespace ID */ getNamespaceId(): string | null { return this.namespaceId } /** * Encrypt content * Returns plaintext if encryption is disabled */ async encrypt(content: string): Promise { if (!this.encryptionEnabled || !this.masterKey) { // In development without HTTPS, return content as-is (base64 encoded for consistency) return btoa(unescape(encodeURIComponent(content))) } return encryptContent(content, this.masterKey) } /** * Decrypt content * Returns content as-is if encryption is disabled */ async decrypt(encrypted: string): Promise { if (!this.encryptionEnabled || !this.masterKey) { // In development without HTTPS, decode base64 try { return decodeURIComponent(escape(atob(encrypted))) } catch { return encrypted } } return decryptContent(encrypted, this.masterKey) } }