Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
studio-v2/lib/voice/index.ts
Normal file
13
studio-v2/lib/voice/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Voice Service Library
|
||||
* Client-side voice streaming and encryption
|
||||
*/
|
||||
|
||||
export { VoiceAPI, type VoiceSession, type VoiceTask } from './voice-api'
|
||||
export {
|
||||
VoiceEncryption,
|
||||
generateMasterKey,
|
||||
generateKeyHash,
|
||||
encryptContent,
|
||||
decryptContent,
|
||||
} from './voice-encryption'
|
||||
400
studio-v2/lib/voice/voice-api.ts
Normal file
400
studio-v2/lib/voice/voice-api.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Voice API Client
|
||||
* WebSocket-based voice streaming to voice-service
|
||||
*/
|
||||
|
||||
import { VoiceEncryption } from './voice-encryption'
|
||||
|
||||
const VOICE_SERVICE_URL =
|
||||
process.env.NEXT_PUBLIC_VOICE_SERVICE_URL || 'http://localhost:8091'
|
||||
const WS_URL = VOICE_SERVICE_URL.replace('http', 'ws')
|
||||
|
||||
export interface VoiceSession {
|
||||
id: string
|
||||
namespace_id: string
|
||||
status: string
|
||||
created_at: string
|
||||
websocket_url: string
|
||||
}
|
||||
|
||||
export interface VoiceTask {
|
||||
id: string
|
||||
session_id: string
|
||||
type: string
|
||||
state: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
result_available: boolean
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface TranscriptMessage {
|
||||
type: 'transcript'
|
||||
text: string
|
||||
final: boolean
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface IntentMessage {
|
||||
type: 'intent'
|
||||
intent: string
|
||||
confidence: number
|
||||
parameters: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ResponseMessage {
|
||||
type: 'response'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface StatusMessage {
|
||||
type: 'status'
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface TaskCreatedMessage {
|
||||
type: 'task_created'
|
||||
task_id: string
|
||||
task_type: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
type: 'error'
|
||||
message: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export type VoiceMessage =
|
||||
| TranscriptMessage
|
||||
| IntentMessage
|
||||
| ResponseMessage
|
||||
| StatusMessage
|
||||
| TaskCreatedMessage
|
||||
| ErrorMessage
|
||||
|
||||
export type VoiceEventHandler = (message: VoiceMessage) => void
|
||||
export type AudioHandler = (audioData: ArrayBuffer) => void
|
||||
export type ErrorHandler = (error: Error) => void
|
||||
|
||||
/**
|
||||
* Voice API Client
|
||||
* Handles session management and WebSocket streaming
|
||||
*/
|
||||
export class VoiceAPI {
|
||||
private encryption: VoiceEncryption
|
||||
private session: VoiceSession | null = null
|
||||
private ws: WebSocket | null = null
|
||||
private audioContext: AudioContext | null = null
|
||||
private mediaStream: MediaStream | null = null
|
||||
private processor: ScriptProcessorNode | null = null
|
||||
|
||||
private onMessage: VoiceEventHandler | null = null
|
||||
private onAudio: AudioHandler | null = null
|
||||
private onError: ErrorHandler | null = null
|
||||
private onStatusChange: ((status: string) => void) | null = null
|
||||
|
||||
constructor() {
|
||||
this.encryption = new VoiceEncryption()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the voice API
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.encryption.initialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API is ready
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return this.encryption.isInitialized()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new voice session
|
||||
*/
|
||||
async createSession(): Promise<VoiceSession> {
|
||||
const namespaceId = this.encryption.getNamespaceId()
|
||||
const keyHash = this.encryption.getKeyHash()
|
||||
|
||||
if (!namespaceId || !keyHash) {
|
||||
throw new Error('Encryption not initialized')
|
||||
}
|
||||
|
||||
const response = await fetch(`${VOICE_SERVICE_URL}/api/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
namespace_id: namespaceId,
|
||||
key_hash: keyHash,
|
||||
device_type: 'pwa',
|
||||
client_version: '1.0.0',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create session: ${response.statusText}`)
|
||||
}
|
||||
|
||||
this.session = await response.json()
|
||||
return this.session!
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket for voice streaming
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (!this.session) {
|
||||
await this.createSession()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = this.session!.websocket_url
|
||||
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
this.onStatusChange?.('connected')
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
console.error('WebSocket error:', event)
|
||||
this.onError?.(new Error('WebSocket connection failed'))
|
||||
reject(new Error('WebSocket connection failed'))
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket closed')
|
||||
this.onStatusChange?.('disconnected')
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
if (event.data instanceof Blob) {
|
||||
// Binary audio data
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
this.onAudio?.(buffer)
|
||||
})
|
||||
} else {
|
||||
// JSON message
|
||||
try {
|
||||
const message = JSON.parse(event.data) as VoiceMessage
|
||||
this.onMessage?.(message)
|
||||
|
||||
if (message.type === 'status') {
|
||||
this.onStatusChange?.(message.status)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse message:', event.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start capturing audio from microphone
|
||||
*/
|
||||
async startCapture(): Promise<void> {
|
||||
try {
|
||||
// Request microphone access
|
||||
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: 24000,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Create audio context
|
||||
this.audioContext = new AudioContext({ sampleRate: 24000 })
|
||||
const source = this.audioContext.createMediaStreamSource(this.mediaStream)
|
||||
|
||||
// Create processor for capturing audio
|
||||
// Note: ScriptProcessorNode is deprecated but still widely supported
|
||||
// In production, use AudioWorklet
|
||||
this.processor = this.audioContext.createScriptProcessor(2048, 1, 1)
|
||||
|
||||
this.processor.onaudioprocess = (event) => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
|
||||
|
||||
const inputData = event.inputBuffer.getChannelData(0)
|
||||
|
||||
// Convert Float32 to Int16
|
||||
const int16Data = new Int16Array(inputData.length)
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, inputData[i]))
|
||||
int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7fff
|
||||
}
|
||||
|
||||
// Send audio chunk
|
||||
this.ws.send(int16Data.buffer)
|
||||
}
|
||||
|
||||
source.connect(this.processor)
|
||||
this.processor.connect(this.audioContext.destination)
|
||||
|
||||
this.onStatusChange?.('listening')
|
||||
} catch (error) {
|
||||
console.error('Failed to start capture:', error)
|
||||
this.onError?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop capturing audio
|
||||
*/
|
||||
stopCapture(): void {
|
||||
if (this.processor) {
|
||||
this.processor.disconnect()
|
||||
this.processor = null
|
||||
}
|
||||
|
||||
if (this.mediaStream) {
|
||||
this.mediaStream.getTracks().forEach((track) => track.stop())
|
||||
this.mediaStream = null
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close()
|
||||
this.audioContext = null
|
||||
}
|
||||
|
||||
// Signal end of turn
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'end_turn' }))
|
||||
}
|
||||
|
||||
this.onStatusChange?.('processing')
|
||||
}
|
||||
|
||||
/**
|
||||
* Send interrupt signal
|
||||
*/
|
||||
interrupt(): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'interrupt' }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from voice service
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
this.stopCapture()
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
if (this.session) {
|
||||
try {
|
||||
await fetch(
|
||||
`${VOICE_SERVICE_URL}/api/v1/sessions/${this.session.id}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn('Failed to close session:', e)
|
||||
}
|
||||
this.session = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending tasks for current session
|
||||
*/
|
||||
async getTasks(): Promise<VoiceTask[]> {
|
||||
if (!this.session) return []
|
||||
|
||||
const response = await fetch(
|
||||
`${VOICE_SERVICE_URL}/api/v1/sessions/${this.session.id}/tasks`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get tasks')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a task
|
||||
*/
|
||||
async approveTask(taskId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${VOICE_SERVICE_URL}/api/v1/tasks/${taskId}/transition`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
new_state: 'approved',
|
||||
reason: 'user_approved',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to approve task')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a task
|
||||
*/
|
||||
async rejectTask(taskId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${VOICE_SERVICE_URL}/api/v1/tasks/${taskId}/transition`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
new_state: 'rejected',
|
||||
reason: 'user_rejected',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to reject task')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event handlers
|
||||
*/
|
||||
setOnMessage(handler: VoiceEventHandler): void {
|
||||
this.onMessage = handler
|
||||
}
|
||||
|
||||
setOnAudio(handler: AudioHandler): void {
|
||||
this.onAudio = handler
|
||||
}
|
||||
|
||||
setOnError(handler: ErrorHandler): void {
|
||||
this.onError = handler
|
||||
}
|
||||
|
||||
setOnStatusChange(handler: (status: string) => void): void {
|
||||
this.onStatusChange = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
*/
|
||||
getSession(): VoiceSession | null {
|
||||
return this.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
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