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:
298
klausur-service/frontend/src/services/encryption.ts
Normal file
298
klausur-service/frontend/src/services/encryption.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
Reference in New Issue
Block a user