""" Encryption Service - Namespace Key Management Client-side encryption for DSGVO compliance The encryption key NEVER leaves the teacher's device. Server only sees: - Key hash (for verification) - Encrypted blobs - Namespace ID (pseudonym) """ import structlog import hashlib import base64 import secrets from typing import Optional from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from config import settings logger = structlog.get_logger(__name__) class EncryptionService: """ Handles namespace key verification and server-side encryption. Important: This service does NOT have access to the actual encryption key. The key is stored only on the teacher's device. This service only verifies key hashes and manages encrypted blobs. """ def __init__(self): self._key_hashes: dict[str, str] = {} # namespace_id -> key_hash self._server_key = secrets.token_bytes(32) # Server-side encryption for transit def verify_key_hash(self, key_hash: str) -> bool: """ Verify that a key hash is valid format. Does NOT verify the actual key - that's client-side only. Accepts "disabled" for development over HTTP (where crypto.subtle is unavailable). In production, always use HTTPS to enable proper encryption. """ if not key_hash: return False # Allow "disabled" for development (HTTP context where crypto.subtle is unavailable) if key_hash == "disabled": logger.warning( "Encryption disabled - client running in non-secure context (HTTP). " "Use HTTPS in production!" ) return True # Expected format: "sha256:base64encodedHash" if not key_hash.startswith("sha256:"): return False try: hash_part = key_hash[7:] # Remove "sha256:" prefix decoded = base64.b64decode(hash_part) return len(decoded) == 32 # SHA-256 produces 32 bytes except Exception: return False def register_namespace_key(self, namespace_id: str, key_hash: str) -> bool: """ Register a namespace's key hash for future verification. """ if not self.verify_key_hash(key_hash): logger.warning("Invalid key hash format", namespace_id=namespace_id[:8]) return False self._key_hashes[namespace_id] = key_hash if key_hash == "disabled": logger.info("Namespace registered (encryption disabled)", namespace_id=namespace_id[:8]) else: logger.info("Namespace key registered", namespace_id=namespace_id[:8]) return True def encrypt_content(self, plaintext: str, namespace_id: str) -> str: """ Encrypt content for server-side storage. Note: This is transit encryption only. The actual client-side encryption happens in the browser/app. This adds an additional layer for data at rest on the server. """ if not settings.encryption_enabled: return plaintext try: # Derive key from server key + namespace derived_key = self._derive_key(namespace_id) # Generate nonce nonce = secrets.token_bytes(12) # Encrypt aesgcm = AESGCM(derived_key) ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None) # Combine nonce + ciphertext and encode encrypted = base64.b64encode(nonce + ciphertext).decode('utf-8') return f"encrypted:{encrypted}" except Exception as e: logger.error("Encryption failed", error=str(e)) raise def decrypt_content(self, encrypted: str, namespace_id: str) -> str: """ Decrypt server-side encrypted content. """ if not settings.encryption_enabled: return encrypted if not encrypted.startswith("encrypted:"): return encrypted # Not encrypted try: # Decode encoded = encrypted[10:] # Remove "encrypted:" prefix data = base64.b64decode(encoded) # Split nonce and ciphertext nonce = data[:12] ciphertext = data[12:] # Derive key from server key + namespace derived_key = self._derive_key(namespace_id) # Decrypt aesgcm = AESGCM(derived_key) plaintext = aesgcm.decrypt(nonce, ciphertext, None) return plaintext.decode('utf-8') except Exception as e: logger.error("Decryption failed", error=str(e)) raise def _derive_key(self, namespace_id: str) -> bytes: """ Derive a key from server key + namespace ID. This ensures each namespace has a unique encryption key. """ kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=namespace_id.encode('utf-8'), iterations=100000, ) return kdf.derive(self._server_key) @staticmethod def generate_key_hash(key: bytes) -> str: """ Generate a key hash for client-side use. This is a utility method - actual implementation is in the client. """ hash_bytes = hashlib.sha256(key).digest() encoded = base64.b64encode(hash_bytes).decode('utf-8') return f"sha256:{encoded}" @staticmethod def generate_namespace_id() -> str: """ Generate a new namespace ID for a teacher. """ return f"ns-{secrets.token_hex(16)}" class ClientSideEncryption: """ Helper class documenting client-side encryption. This code runs in the browser/app, not on the server. Client-side encryption flow: 1. Teacher generates a master key on first use 2. Master key is stored in browser/app secure storage 3. Key hash is sent to server for session verification 4. All PII is encrypted with master key before sending to server 5. Server only sees encrypted blobs JavaScript implementation: ```javascript // Generate master key (one-time) const masterKey = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); // Store in IndexedDB (encrypted with device key) await storeSecurely("masterKey", masterKey); // Generate key hash for server const keyData = await crypto.subtle.exportKey("raw", masterKey); const hashBuffer = await crypto.subtle.digest("SHA-256", keyData); const keyHash = "sha256:" + btoa(String.fromCharCode(...new Uint8Array(hashBuffer))); // Encrypt content before sending async function encryptContent(content) { const iv = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(content); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, masterKey, encoded ); return btoa(String.fromCharCode(...iv, ...new Uint8Array(ciphertext))); } // Decrypt content after receiving async function decryptContent(encrypted) { 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 }, masterKey, ciphertext ); return new TextDecoder().decode(decrypted); } ``` """ pass