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/klausur-service/frontend/src/services/encryption.ts
Benjamin Admin bfdaf63ba9 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

299 lines
7.4 KiB
TypeScript

/**
* BYOEH Client-Side Encryption Service
*
* Provides AES-256-GCM encryption for Erwartungshorizonte.
* The passphrase NEVER leaves the browser - only the encrypted data
* and a hash of the derived key are sent to the server.
*
* Security Flow:
* 1. User enters passphrase
* 2. PBKDF2 derives a 256-bit key from passphrase + random salt
* 3. AES-256-GCM encrypts the file content
* 4. SHA-256 hash of derived key is created for server-side verification
* 5. Encrypted blob + key hash + salt are uploaded (NOT the passphrase!)
*/
export interface EncryptionResult {
encryptedData: ArrayBuffer
keyHash: string
salt: string
iv: string
}
export interface DecryptionResult {
decryptedData: ArrayBuffer
success: boolean
error?: string
}
/**
* Convert ArrayBuffer to hex string
*/
function bufferToHex(buffer: Uint8Array): string {
return Array.from(buffer)
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Convert hex string to ArrayBuffer
*/
function hexToBuffer(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Derive an AES-256 key from passphrase using PBKDF2
*/
async function deriveKey(
passphrase: string,
salt: Uint8Array
): Promise<CryptoKey> {
// Import passphrase as key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
'PBKDF2',
false,
['deriveKey']
)
// Derive AES-256-GCM key using PBKDF2
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt.buffer as ArrayBuffer,
iterations: 100000, // High iteration count for security
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true, // extractable for hashing
['encrypt', 'decrypt']
)
}
/**
* Create SHA-256 hash of the derived key for server verification
*/
async function hashKey(key: CryptoKey): Promise<string> {
const rawKey = await crypto.subtle.exportKey('raw', key)
const hashBuffer = await crypto.subtle.digest('SHA-256', rawKey)
return bufferToHex(new Uint8Array(hashBuffer))
}
/**
* Encrypt a file using AES-256-GCM
*
* @param file - File to encrypt
* @param passphrase - User's passphrase (never sent to server)
* @returns Encrypted data + metadata for upload
*/
export async function encryptFile(
file: File,
passphrase: string
): Promise<EncryptionResult> {
// 1. Generate random 16-byte salt
const salt = crypto.getRandomValues(new Uint8Array(16))
// 2. Derive key from passphrase
const key = await deriveKey(passphrase, salt)
// 3. Generate random 12-byte IV (required for AES-GCM)
const iv = crypto.getRandomValues(new Uint8Array(12))
// 4. Read file content
const fileBuffer = await file.arrayBuffer()
// 5. Encrypt using AES-256-GCM
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
fileBuffer
)
// 6. Create key hash for server-side verification
const keyHash = await hashKey(key)
// 7. Combine IV + ciphertext (IV is needed for decryption)
const combined = new Uint8Array(iv.length + encrypted.byteLength)
combined.set(iv, 0)
combined.set(new Uint8Array(encrypted), iv.length)
return {
encryptedData: combined.buffer,
keyHash,
salt: bufferToHex(salt),
iv: bufferToHex(iv)
}
}
/**
* Encrypt text content using AES-256-GCM
*
* @param text - Text to encrypt
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Base64-encoded encrypted content
*/
export async function encryptText(
text: string,
passphrase: string,
saltHex: string
): Promise<string> {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(text)
)
// Combine IV + ciphertext
const combined = new Uint8Array(iv.length + encrypted.byteLength)
combined.set(iv, 0)
combined.set(new Uint8Array(encrypted), iv.length)
// Return as base64
return btoa(String.fromCharCode(...combined))
}
/**
* Decrypt text content using AES-256-GCM
*
* @param encryptedBase64 - Base64-encoded encrypted content (IV + ciphertext)
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Decrypted text
*/
export async function decryptText(
encryptedBase64: string,
passphrase: string,
saltHex: string
): Promise<string> {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
// Decode base64
const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0))
// Extract IV (first 12 bytes) and ciphertext
const iv = combined.slice(0, 12)
const ciphertext = combined.slice(12)
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
return new TextDecoder().decode(decrypted)
}
/**
* Decrypt a file using AES-256-GCM
*
* @param encryptedData - Encrypted data (IV + ciphertext)
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Decrypted file content
*/
export async function decryptFile(
encryptedData: ArrayBuffer,
passphrase: string,
saltHex: string
): Promise<DecryptionResult> {
try {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
const combined = new Uint8Array(encryptedData)
// Extract IV (first 12 bytes) and ciphertext
const iv = combined.slice(0, 12)
const ciphertext = combined.slice(12)
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
return {
decryptedData: decrypted,
success: true
}
} catch (error) {
return {
decryptedData: new ArrayBuffer(0),
success: false,
error: error instanceof Error ? error.message : 'Decryption failed'
}
}
}
/**
* Verify a passphrase against stored key hash
*
* @param passphrase - Passphrase to verify
* @param saltHex - Salt as hex string
* @param expectedHash - Expected key hash
* @returns true if passphrase is correct
*/
export async function verifyPassphrase(
passphrase: string,
saltHex: string,
expectedHash: string
): Promise<boolean> {
try {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
const computedHash = await hashKey(key)
return computedHash === expectedHash
} catch {
return false
}
}
/**
* Generate a key hash for a given passphrase and salt
* Used when creating a new encrypted document
*
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Key hash for storage
*/
export async function generateKeyHash(
passphrase: string,
saltHex: string
): Promise<string> {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
return hashKey(key)
}
/**
* Generate a random salt for encryption
*
* @returns 16-byte salt as hex string
*/
export function generateSalt(): string {
const salt = crypto.getRandomValues(new Uint8Array(16))
return bufferToHex(salt)
}
/**
* Check if Web Crypto API is available
*/
export function isEncryptionSupported(): boolean {
return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'
}