This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/studio-v2/lib/voice/voice-encryption.ts
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

335 lines
8.7 KiB
TypeScript

/**
* 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<IDBDatabase> {
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<CryptoKey | null> {
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<void> {
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<CryptoKey | null> {
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<string> {
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<string> {
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<string> {
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<string> {
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<void> {
// 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<string> {
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<string> {
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)
}
}