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>
299 lines
7.4 KiB
TypeScript
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'
|
|
}
|