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>
335 lines
8.7 KiB
TypeScript
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)
|
|
}
|
|
}
|