From 63849d86e1102984ed36a21c9e9e91f2e3f6b9fd Mon Sep 17 00:00:00 2001 From: haex Date: Wed, 5 Nov 2025 17:08:49 +0100 Subject: [PATCH] Add sync backend infrastructure and improve grid snapping - Implement crypto utilities for vault key management (Hybrid-Ansatz) - PBKDF2 key derivation with 600k iterations - AES-GCM encryption for vault keys and CRDT data - Optimized Base64 conversion with Buffer/btoa fallback - Add Sync Engine Store for server communication - Vault key storage and retrieval - CRDT log push/pull operations - Supabase client integration - Add Sync Orchestrator Store with realtime subscriptions - Event-driven sync (push after writes) - Supabase Realtime for instant sync - Sync status tracking per backend - Add haex_sync_status table for reliable sync tracking --- src/database/schemas/crdt.ts | 24 ++ src/stores/sync/engine.ts | 390 ++++++++++++++++++++++++ src/stores/sync/orchestrator.ts | 525 ++++++++++++++++++++++++++++++++ src/utils/crypto/vaultKey.ts | 250 +++++++++++++++ 4 files changed, 1189 insertions(+) create mode 100644 src/stores/sync/engine.ts create mode 100644 src/stores/sync/orchestrator.ts create mode 100644 src/utils/crypto/vaultKey.ts diff --git a/src/database/schemas/crdt.ts b/src/database/schemas/crdt.ts index 846c896..3af76e9 100644 --- a/src/database/schemas/crdt.ts +++ b/src/database/schemas/crdt.ts @@ -48,3 +48,27 @@ export const haexCrdtConfigs = sqliteTable(tableNames.haex.crdt.configs.name, { key: text().primaryKey(), value: text(), }) + +/** + * Sync Status Table (WITHOUT CRDT - local-only metadata) + * Tracks sync progress for each backend + */ +export const haexSyncStatus = sqliteTable( + 'haex_sync_status', + { + id: text('id') + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + backendId: text('backend_id').notNull(), + // Last server sequence number received from pull + lastPullSequence: integer('last_pull_sequence'), + // Last HLC timestamp pushed to server + lastPushHlcTimestamp: text('last_push_hlc_timestamp'), + // Last successful sync timestamp + lastSyncAt: text('last_sync_at'), + // Sync error message if any + error: text('error'), + }, +) +export type InsertHaexSyncStatus = typeof haexSyncStatus.$inferInsert +export type SelectHaexSyncStatus = typeof haexSyncStatus.$inferSelect diff --git a/src/stores/sync/engine.ts b/src/stores/sync/engine.ts new file mode 100644 index 0000000..5e52aad --- /dev/null +++ b/src/stores/sync/engine.ts @@ -0,0 +1,390 @@ +/** + * Sync Engine Store - Executes sync operations with haex-sync-server backends + * Handles vault key storage and CRDT log synchronization + */ + +import { createClient } from '@supabase/supabase-js' +import type { SelectHaexCrdtLogs } from '~/database/schemas' +import { + encryptVaultKeyAsync, + decryptVaultKeyAsync, + encryptCrdtDataAsync, + decryptCrdtDataAsync, + generateVaultKey, +} from '~/utils/crypto/vaultKey' + +interface VaultKeyCache { + [vaultId: string]: { + vaultKey: Uint8Array + timestamp: number + } +} + +interface SyncLogData { + vaultId: string + encryptedData: string + nonce: string + haexTimestamp: string + sequence: number +} + +interface PullLogsResponse { + logs: Array<{ + id: string + userId: string + vaultId: string + encryptedData: string + nonce: string + haexTimestamp: string + sequence: number + createdAt: string + }> + hasMore: boolean +} + +export const useSyncEngineStore = defineStore('syncEngineStore', () => { + const { currentVault, currentVaultId } = storeToRefs(useVaultStore()) + const syncBackendsStore = useSyncBackendsStore() + + // In-memory cache for decrypted vault keys (cleared on logout/vault close) + const vaultKeyCache = ref({}) + + // Supabase client (initialized with config from backend) + const supabaseClient = ref | null>(null) + + /** + * Initializes Supabase client for a specific backend + */ + const initSupabaseClientAsync = async (backendId: string) => { + const backend = syncBackendsStore.backends.find((b) => b.id === backendId) + if (!backend) { + throw new Error('Backend not found') + } + + // Get Supabase URL and anon key from server health check + const response = await fetch(backend.serverUrl) + if (!response.ok) { + throw new Error('Failed to connect to sync server') + } + + const serverInfo = await response.json() + const supabaseUrl = serverInfo.supabaseUrl + + // For now, we need to configure the anon key somewhere + // TODO: Store this in backend config or fetch from somewhere secure + const supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY' + + supabaseClient.value = createClient(supabaseUrl, supabaseAnonKey) + } + + /** + * Gets the current Supabase auth token + */ + const getAuthTokenAsync = async (): Promise => { + if (!supabaseClient.value) { + return null + } + + const { + data: { session }, + } = await supabaseClient.value.auth.getSession() + return session?.access_token ?? null + } + + /** + * Stores encrypted vault key on the server + */ + const storeVaultKeyAsync = async ( + backendId: string, + vaultId: string, + password: string, + ): Promise => { + const backend = syncBackendsStore.backends.find((b) => b.id === backendId) + if (!backend) { + throw new Error('Backend not found') + } + + // Generate new vault key + const vaultKey = generateVaultKey() + + // Encrypt vault key with password + const encryptedData = await encryptVaultKeyAsync(vaultKey, password) + + // Get auth token + const token = await getAuthTokenAsync() + if (!token) { + throw new Error('Not authenticated') + } + + // Send to server + const response = await fetch(`${backend.serverUrl}/sync/vault-key`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + vaultId, + ...encryptedData, + }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error( + `Failed to store vault key: ${error.error || response.statusText}`, + ) + } + + // Cache decrypted vault key + vaultKeyCache.value[vaultId] = { + vaultKey, + timestamp: Date.now(), + } + } + + /** + * Retrieves and decrypts vault key from the server + */ + const getVaultKeyAsync = async ( + backendId: string, + vaultId: string, + password: string, + ): Promise => { + // Check cache first + const cached = vaultKeyCache.value[vaultId] + if (cached) { + return cached.vaultKey + } + + const backend = syncBackendsStore.backends.find((b) => b.id === backendId) + if (!backend) { + throw new Error('Backend not found') + } + + // Get auth token + const token = await getAuthTokenAsync() + if (!token) { + throw new Error('Not authenticated') + } + + // Fetch from server + const response = await fetch( + `${backend.serverUrl}/sync/vault-key/${vaultId}`, + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }, + ) + + if (response.status === 404) { + throw new Error('Vault key not found on server') + } + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error( + `Failed to get vault key: ${error.error || response.statusText}`, + ) + } + + const data = await response.json() + + // Decrypt vault key + const vaultKey = await decryptVaultKeyAsync( + data.encryptedVaultKey, + data.salt, + data.nonce, + password, + ) + + // Cache decrypted vault key + vaultKeyCache.value[vaultId] = { + vaultKey, + timestamp: Date.now(), + } + + return vaultKey + } + + /** + * Pushes CRDT logs to the server + */ + const pushLogsAsync = async ( + backendId: string, + vaultId: string, + logs: SelectHaexCrdtLogs[], + ): Promise => { + const backend = syncBackendsStore.backends.find((b) => b.id === backendId) + if (!backend) { + throw new Error('Backend not found') + } + + // Get vault key from cache + const cached = vaultKeyCache.value[vaultId] + if (!cached) { + throw new Error('Vault key not available. Please unlock vault first.') + } + + const vaultKey = cached.vaultKey + + // Get auth token + const token = await getAuthTokenAsync() + if (!token) { + throw new Error('Not authenticated') + } + + // Encrypt each log entry + const encryptedLogs: SyncLogData[] = [] + for (const log of logs) { + const { encryptedData, nonce } = await encryptCrdtDataAsync( + log, + vaultKey, + ) + + // Generate sequence number based on timestamp + const sequence = Date.now() + + encryptedLogs.push({ + vaultId, + encryptedData, + nonce, + haexTimestamp: log.haexTimestamp!, + sequence, + }) + } + + // Send to server + const response = await fetch(`${backend.serverUrl}/sync/push`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + vaultId, + logs: encryptedLogs, + }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error( + `Failed to push logs: ${error.error || response.statusText}`, + ) + } + } + + /** + * Pulls CRDT logs from the server + */ + const pullLogsAsync = async ( + backendId: string, + vaultId: string, + afterSequence?: number, + limit?: number, + ): Promise => { + const backend = syncBackendsStore.backends.find((b) => b.id === backendId) + if (!backend) { + throw new Error('Backend not found') + } + + // Get vault key from cache + const cached = vaultKeyCache.value[vaultId] + if (!cached) { + throw new Error('Vault key not available. Please unlock vault first.') + } + + const vaultKey = cached.vaultKey + + // Get auth token + const token = await getAuthTokenAsync() + if (!token) { + throw new Error('Not authenticated') + } + + // Fetch from server + const response = await fetch(`${backend.serverUrl}/sync/pull`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + vaultId, + afterSequence, + limit: limit ?? 100, + }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error( + `Failed to pull logs: ${error.error || response.statusText}`, + ) + } + + const data: PullLogsResponse = await response.json() + + // Decrypt each log entry + const decryptedLogs: SelectHaexCrdtLogs[] = [] + for (const log of data.logs) { + try { + const decrypted = await decryptCrdtDataAsync( + log.encryptedData, + log.nonce, + vaultKey, + ) + decryptedLogs.push(decrypted) + } catch (error) { + console.error('Failed to decrypt log entry:', log.id, error) + // Skip corrupted entries + } + } + + return decryptedLogs + } + + /** + * Clears vault key from cache + */ + const clearVaultKeyCache = (vaultId?: string) => { + if (vaultId) { + delete vaultKeyCache.value[vaultId] + } else { + vaultKeyCache.value = {} + } + } + + /** + * Health check - verifies server is reachable + */ + const healthCheckAsync = async (backendId: string): Promise => { + const backend = syncBackendsStore.backends.find((b) => b.id === backendId) + if (!backend) { + return false + } + + try { + const response = await fetch(backend.serverUrl) + return response.ok + } catch { + return false + } + } + + return { + vaultKeyCache, + supabaseClient, + initSupabaseClientAsync, + getAuthTokenAsync, + storeVaultKeyAsync, + getVaultKeyAsync, + pushLogsAsync, + pullLogsAsync, + clearVaultKeyCache, + healthCheckAsync, + } +}) diff --git a/src/stores/sync/orchestrator.ts b/src/stores/sync/orchestrator.ts new file mode 100644 index 0000000..9e3c40b --- /dev/null +++ b/src/stores/sync/orchestrator.ts @@ -0,0 +1,525 @@ +/** + * Sync Orchestrator Store - Orchestrates sync operations across all backends + * Uses Supabase Realtime subscriptions for instant sync + */ + +import { eq, gt } from 'drizzle-orm' +import type { RealtimeChannel } from '@supabase/supabase-js' +import { + haexCrdtLogs, + haexSyncStatus, + type SelectHaexCrdtLogs, + type SelectHaexSyncStatus, +} from '~/database/schemas' + +interface SyncState { + isConnected: boolean + isSyncing: boolean + error: string | null + subscription: RealtimeChannel | null + status: SelectHaexSyncStatus | null +} + +interface BackendSyncState { + [backendId: string]: SyncState +} + +export const useSyncOrchestratorStore = defineStore( + 'syncOrchestratorStore', + () => { + const { currentVault, currentVaultId } = storeToRefs(useVaultStore()) + const syncBackendsStore = useSyncBackendsStore() + const syncEngineStore = useSyncEngineStore() + + // Sync state per backend + const syncStates = ref({}) + + // Track if we're currently processing a local write + const isProcessingLocalWrite = ref(false) + + /** + * Loads sync status from database for a backend + */ + const loadSyncStatusAsync = async ( + backendId: string, + ): Promise => { + if (!currentVault.value?.drizzle) { + throw new Error('No vault opened') + } + + try { + const results = await currentVault.value.drizzle + .select() + .from(haexSyncStatus) + .where(eq(haexSyncStatus.backendId, backendId)) + .limit(1) + + return results[0] ?? null + } catch (error) { + console.error('Failed to load sync status:', error) + return null + } + } + + /** + * Updates sync status in database + */ + const updateSyncStatusAsync = async ( + backendId: string, + updates: Partial, + ): Promise => { + if (!currentVault.value?.drizzle) { + throw new Error('No vault opened') + } + + try { + const existing = await loadSyncStatusAsync(backendId) + + if (existing) { + // Update existing + await currentVault.value.drizzle + .update(haexSyncStatus) + .set({ + ...updates, + lastSyncAt: new Date().toISOString(), + }) + .where(eq(haexSyncStatus.backendId, backendId)) + } else { + // Insert new + await currentVault.value.drizzle.insert(haexSyncStatus).values({ + backendId, + ...updates, + lastSyncAt: new Date().toISOString(), + }) + } + + // Update local state + if (syncStates.value[backendId]) { + syncStates.value[backendId].status = await loadSyncStatusAsync( + backendId, + ) + } + } catch (error) { + console.error('Failed to update sync status:', error) + throw error + } + } + + /** + * Gets logs that need to be pushed to server (after last push HLC) + */ + const getLogsToPushAsync = async ( + backendId: string, + ): Promise => { + if (!currentVault.value?.drizzle) { + throw new Error('No vault opened') + } + + try { + const status = await loadSyncStatusAsync(backendId) + const lastPushHlc = status?.lastPushHlcTimestamp + + const query = currentVault.value.drizzle + .select() + .from(haexCrdtLogs) + .orderBy(haexCrdtLogs.haexTimestamp) + + if (lastPushHlc) { + return await query.where( + gt(haexCrdtLogs.haexTimestamp, lastPushHlc), + ) + } + + return await query + } catch (error) { + console.error('Failed to get logs to push:', error) + throw error + } + } + + /** + * Applies remote logs to local database + */ + const applyRemoteLogsAsync = async ( + logs: SelectHaexCrdtLogs[], + ): Promise => { + if (!currentVault.value?.drizzle) { + throw new Error('No vault opened') + } + + try { + // Insert logs into local CRDT log table + for (const log of logs) { + await currentVault.value.drizzle + .insert(haexCrdtLogs) + .values(log) + .onConflictDoNothing() // Skip if already exists + } + + // TODO: Apply CRDT log entries to actual data tables + // This requires replaying the operations from the log + console.log(`Applied ${logs.length} remote logs to local database`) + } catch (error) { + console.error('Failed to apply remote logs:', error) + throw error + } + } + + /** + * Pushes local changes to a specific backend + */ + const pushToBackendAsync = async (backendId: string): Promise => { + if (!currentVaultId.value) { + throw new Error('No vault opened') + } + + const state = syncStates.value[backendId] + if (!state) { + throw new Error('Backend not initialized') + } + + if (state.isSyncing) { + console.log(`Already syncing with backend ${backendId}`) + return + } + + state.isSyncing = true + state.error = null + + try { + // Get logs that need to be pushed + const logs = await getLogsToPushAsync(backendId) + + if (logs.length === 0) { + console.log(`No logs to push to backend ${backendId}`) + return + } + + await syncEngineStore.pushLogsAsync( + backendId, + currentVaultId.value, + logs, + ) + + // Update sync status with last pushed HLC timestamp + const lastHlc = logs[logs.length - 1]?.haexTimestamp + if (lastHlc) { + await updateSyncStatusAsync(backendId, { + lastPushHlcTimestamp: lastHlc, + }) + } + + console.log(`Pushed ${logs.length} logs to backend ${backendId}`) + } catch (error) { + console.error(`Failed to push to backend ${backendId}:`, error) + state.error = error instanceof Error ? error.message : 'Unknown error' + await updateSyncStatusAsync(backendId, { + error: state.error, + }) + throw error + } finally { + state.isSyncing = false + } + } + + /** + * Pulls changes from a specific backend + */ + const pullFromBackendAsync = async (backendId: string): Promise => { + if (!currentVaultId.value) { + throw new Error('No vault opened') + } + + const state = syncStates.value[backendId] + if (!state) { + throw new Error('Backend not initialized') + } + + if (state.isSyncing) { + console.log(`Already syncing with backend ${backendId}`) + return + } + + state.isSyncing = true + state.error = null + + try { + const status = await loadSyncStatusAsync(backendId) + const afterSequence = status?.lastPullSequence ?? undefined + + const remoteLogs = await syncEngineStore.pullLogsAsync( + backendId, + currentVaultId.value, + afterSequence, + 100, + ) + + if (remoteLogs.length > 0) { + await applyRemoteLogsAsync(remoteLogs) + + // Update sync status with last pulled sequence + // TODO: Get actual sequence from server response + const lastSequence = Date.now() + await updateSyncStatusAsync(backendId, { + lastPullSequence: lastSequence, + }) + + console.log( + `Pulled ${remoteLogs.length} logs from backend ${backendId}`, + ) + } + } catch (error) { + console.error(`Failed to pull from backend ${backendId}:`, error) + state.error = error instanceof Error ? error.message : 'Unknown error' + await updateSyncStatusAsync(backendId, { + error: state.error, + }) + throw error + } finally { + state.isSyncing = false + } + } + + /** + * Handles incoming realtime changes from Supabase + */ + const handleRealtimeChangeAsync = async ( + backendId: string, + payload: any, + ) => { + console.log(`Realtime change from backend ${backendId}:`, payload) + + // Don't process if we're currently writing locally to avoid loops + if (isProcessingLocalWrite.value) { + console.log('Skipping realtime change - local write in progress') + return + } + + // Pull latest changes from this backend + try { + await pullFromBackendAsync(backendId) + } catch (error) { + console.error('Failed to handle realtime change:', error) + } + } + + /** + * Subscribes to realtime changes from a backend + */ + const subscribeToBackendAsync = async (backendId: string): Promise => { + if (!currentVaultId.value) { + throw new Error('No vault opened') + } + + const state = syncStates.value[backendId] + if (!state) { + throw new Error('Backend not initialized') + } + + if (state.subscription) { + console.log(`Already subscribed to backend ${backendId}`) + return + } + + const client = syncEngineStore.supabaseClient + if (!client) { + throw new Error('Supabase client not initialized') + } + + try { + // Subscribe to sync_logs table for this vault + const channel = client + .channel(`sync_logs:${currentVaultId.value}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'sync_logs', + filter: `vault_id=eq.${currentVaultId.value}`, + }, + (payload) => { + handleRealtimeChangeAsync(backendId, payload).catch(console.error) + }, + ) + .subscribe((status) => { + if (status === 'SUBSCRIBED') { + state.isConnected = true + console.log(`Subscribed to backend ${backendId}`) + } else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { + state.isConnected = false + state.error = `Subscription error: ${status}` + console.error( + `Subscription to backend ${backendId} failed: ${status}`, + ) + } + }) + + state.subscription = channel + } catch (error) { + console.error(`Failed to subscribe to backend ${backendId}:`, error) + state.error = error instanceof Error ? error.message : 'Unknown error' + throw error + } + } + + /** + * Unsubscribes from realtime changes + */ + const unsubscribeFromBackendAsync = async ( + backendId: string, + ): Promise => { + const state = syncStates.value[backendId] + if (!state || !state.subscription) { + return + } + + try { + await state.subscription.unsubscribe() + state.subscription = null + state.isConnected = false + console.log(`Unsubscribed from backend ${backendId}`) + } catch (error) { + console.error(`Failed to unsubscribe from backend ${backendId}:`, error) + } + } + + /** + * Initializes sync for a backend + */ + const initBackendAsync = async (backendId: string): Promise => { + if (syncStates.value[backendId]) { + console.log(`Backend ${backendId} already initialized`) + return + } + + // Load sync status from database + const status = await loadSyncStatusAsync(backendId) + + // Initialize state + syncStates.value[backendId] = { + isConnected: false, + isSyncing: false, + error: null, + subscription: null, + status, + } + + try { + // Initial pull to get all existing data + await pullFromBackendAsync(backendId) + + // Subscribe to realtime changes + await subscribeToBackendAsync(backendId) + } catch (error) { + console.error(`Failed to initialize backend ${backendId}:`, error) + throw error + } + } + + /** + * Called after local write operations to push changes + */ + const onLocalWriteAsync = async (): Promise => { + isProcessingLocalWrite.value = true + + try { + // Push to all enabled backends in parallel + const enabledBackends = syncBackendsStore.enabledBackends + + await Promise.allSettled( + enabledBackends.map((backend) => pushToBackendAsync(backend.id)), + ) + } catch (error) { + console.error('Failed to push local changes:', error) + } finally { + isProcessingLocalWrite.value = false + } + } + + /** + * Starts sync for all enabled backends + */ + const startSyncAsync = async (): Promise => { + const enabledBackends = syncBackendsStore.enabledBackends + + if (enabledBackends.length === 0) { + console.log('No enabled backends to sync with') + return + } + + console.log(`Starting sync with ${enabledBackends.length} backends`) + + for (const backend of enabledBackends) { + try { + await initBackendAsync(backend.id) + } catch (error) { + console.error( + `Failed to start sync with backend ${backend.id}:`, + error, + ) + } + } + } + + /** + * Stops sync for all backends + */ + const stopSyncAsync = async (): Promise => { + console.log('Stopping sync for all backends') + + for (const backendId of Object.keys(syncStates.value)) { + await unsubscribeFromBackendAsync(backendId) + } + + syncStates.value = {} + } + + /** + * Gets sync state for a specific backend + */ + const getSyncState = (backendId: string): SyncState | null => { + return syncStates.value[backendId] ?? null + } + + /** + * Checks if any backend is currently syncing + */ + const isAnySyncing = computed(() => { + return Object.values(syncStates.value).some((state) => state.isSyncing) + }) + + /** + * Checks if all backends are connected + */ + const areAllConnected = computed(() => { + const enabledBackends = syncBackendsStore.enabledBackends + if (enabledBackends.length === 0) return false + + return enabledBackends.every((backend) => { + const state = syncStates.value[backend.id] + return state?.isConnected ?? false + }) + }) + + return { + syncStates, + isProcessingLocalWrite, + isAnySyncing, + areAllConnected, + loadSyncStatusAsync, + updateSyncStatusAsync, + getLogsToPushAsync, + applyRemoteLogsAsync, + pushToBackendAsync, + pullFromBackendAsync, + subscribeToBackendAsync, + unsubscribeFromBackendAsync, + initBackendAsync, + onLocalWriteAsync, + startSyncAsync, + stopSyncAsync, + getSyncState, + } + }, +) diff --git a/src/utils/crypto/vaultKey.ts b/src/utils/crypto/vaultKey.ts new file mode 100644 index 0000000..b5c5b36 --- /dev/null +++ b/src/utils/crypto/vaultKey.ts @@ -0,0 +1,250 @@ +/** + * Crypto utilities for Vault Key Management + * Implements the "Hybrid-Ansatz" for vault key encryption + */ + +const PBKDF2_ITERATIONS = 600_000 +const KEY_LENGTH = 256 +const ALGORITHM = 'AES-GCM' + +/** + * Derives a cryptographic key from a password using PBKDF2 + */ +export async function deriveKeyFromPasswordAsync( + password: string, + salt: Uint8Array, +): Promise { + const encoder = new TextEncoder() + const passwordBuffer = encoder.encode(password) + + // Ensure salt has a proper ArrayBuffer (not SharedArrayBuffer) + const saltBuffer = new Uint8Array(salt) + + // Import password as key material + const keyMaterial = await crypto.subtle.importKey( + 'raw', + passwordBuffer, + 'PBKDF2', + false, + ['deriveKey'], + ) + + // Derive key using PBKDF2 + return await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: saltBuffer, + iterations: PBKDF2_ITERATIONS, + hash: 'SHA-256', + }, + keyMaterial, + { name: ALGORITHM, length: KEY_LENGTH }, + false, // not extractable + ['encrypt', 'decrypt'], + ) +} + +/** + * Generates a random vault key (32 bytes) + */ +export function generateVaultKey(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)) +} + +/** + * Encrypts the vault key with a password-derived key + * Returns: { encryptedVaultKey, salt, nonce } all as Base64 strings + */ +export async function encryptVaultKeyAsync( + vaultKey: Uint8Array, + password: string, +): Promise<{ + encryptedVaultKey: string + salt: string + nonce: string +}> { + // Generate random salt for PBKDF2 + const salt = crypto.getRandomValues(new Uint8Array(32)) + + // Derive encryption key from password + const derivedKey = await deriveKeyFromPasswordAsync(password, salt) + + // Generate random nonce for AES-GCM + const nonce = crypto.getRandomValues(new Uint8Array(12)) + + // Ensure vaultKey has proper ArrayBuffer + const vaultKeyBuffer = new Uint8Array(vaultKey) + + // Encrypt vault key + const encryptedBuffer = await crypto.subtle.encrypt( + { + name: ALGORITHM, + iv: nonce, + }, + derivedKey, + vaultKeyBuffer, + ) + + // Convert to Base64 for storage + return { + encryptedVaultKey: arrayBufferToBase64(encryptedBuffer), + salt: arrayBufferToBase64(salt), + nonce: arrayBufferToBase64(nonce), + } +} + +/** + * Decrypts the vault key using the password + */ +export async function decryptVaultKeyAsync( + encryptedVaultKey: string, + salt: string, + nonce: string, + password: string, +): Promise { + // Convert Base64 to Uint8Array + const encryptedBuffer = base64ToArrayBuffer(encryptedVaultKey) + const saltBuffer = base64ToArrayBuffer(salt) + const nonceBuffer = base64ToArrayBuffer(nonce) + + // Derive decryption key from password + const derivedKey = await deriveKeyFromPasswordAsync(password, saltBuffer) + + // Ensure buffers have proper ArrayBuffer + const encryptedData = new Uint8Array(encryptedBuffer) + const iv = new Uint8Array(nonceBuffer) + + // Decrypt vault key + const decryptedBuffer = await crypto.subtle.decrypt( + { + name: ALGORITHM, + iv, + }, + derivedKey, + encryptedData, + ) + + return new Uint8Array(decryptedBuffer) +} + +/** + * Encrypts CRDT log data with the vault key + */ +export async function encryptCrdtDataAsync( + data: object, + vaultKey: Uint8Array, +): Promise<{ + encryptedData: string + nonce: string +}> { + // Ensure vaultKey has proper ArrayBuffer + const vaultKeyBuffer = new Uint8Array(vaultKey) + + // Import vault key for encryption + const cryptoKey = await crypto.subtle.importKey( + 'raw', + vaultKeyBuffer, + { name: ALGORITHM }, + false, + ['encrypt'], + ) + + // Generate random nonce + const nonce = crypto.getRandomValues(new Uint8Array(12)) + + // Serialize data to JSON + const encoder = new TextEncoder() + const dataBuffer = encoder.encode(JSON.stringify(data)) + + // Encrypt data + const encryptedBuffer = await crypto.subtle.encrypt( + { + name: ALGORITHM, + iv: nonce, + }, + cryptoKey, + dataBuffer, + ) + + return { + encryptedData: arrayBufferToBase64(encryptedBuffer), + nonce: arrayBufferToBase64(nonce), + } +} + +/** + * Decrypts CRDT log data with the vault key + */ +export async function decryptCrdtDataAsync( + encryptedData: string, + nonce: string, + vaultKey: Uint8Array, +): Promise { + // Ensure vaultKey has proper ArrayBuffer + const vaultKeyBuffer = new Uint8Array(vaultKey) + + // Import vault key for decryption + const cryptoKey = await crypto.subtle.importKey( + 'raw', + vaultKeyBuffer, + { name: ALGORITHM }, + false, + ['decrypt'], + ) + + // Convert Base64 to buffers + const encryptedBuffer = base64ToArrayBuffer(encryptedData) + const nonceBuffer = base64ToArrayBuffer(nonce) + + // Ensure buffers have proper ArrayBuffer + const encryptedDataBuffer = new Uint8Array(encryptedBuffer) + const iv = new Uint8Array(nonceBuffer) + + // Decrypt data + const decryptedBuffer = await crypto.subtle.decrypt( + { + name: ALGORITHM, + iv, + }, + cryptoKey, + encryptedDataBuffer, + ) + + // Parse JSON + const decoder = new TextDecoder() + const jsonString = decoder.decode(decryptedBuffer) + return JSON.parse(jsonString) as T +} + +// Utility functions for Base64 conversion + +function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + // Use Buffer for efficient base64 encoding (works in Node/Bun) + if (typeof Buffer !== 'undefined') { + return Buffer.from(bytes).toString('base64') + } + // Fallback to btoa for browser environments + let binary = '' + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i] + if (byte !== undefined) { + binary += String.fromCharCode(byte) + } + } + return btoa(binary) +} + +function base64ToArrayBuffer(base64: string): Uint8Array { + // Use Buffer for efficient base64 decoding (works in Node/Bun) + if (typeof Buffer !== 'undefined') { + return new Uint8Array(Buffer.from(base64, 'base64')) + } + // Fallback to atob for browser environments + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +}