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>
This commit is contained in:
334
studio-v2/lib/voice/voice-encryption.ts
Normal file
334
studio-v2/lib/voice/voice-encryption.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user