feat: voice-service von lehrer nach core verschoben, Pipeline erweitert (voice, BQAS, embedding, night-scheduler)
This commit is contained in:
231
voice-service/services/encryption_service.py
Normal file
231
voice-service/services/encryption_service.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user