mirror of
https://github.com/haexhub/haex-hub.git
synced 2025-12-16 22:20:51 +01:00
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
This commit is contained in:
@ -48,3 +48,27 @@ export const haexCrdtConfigs = sqliteTable(tableNames.haex.crdt.configs.name, {
|
|||||||
key: text().primaryKey(),
|
key: text().primaryKey(),
|
||||||
value: text(),
|
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
|
||||||
|
|||||||
390
src/stores/sync/engine.ts
Normal file
390
src/stores/sync/engine.ts
Normal file
@ -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<VaultKeyCache>({})
|
||||||
|
|
||||||
|
// Supabase client (initialized with config from backend)
|
||||||
|
const supabaseClient = ref<ReturnType<typeof createClient> | 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<string | null> => {
|
||||||
|
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<void> => {
|
||||||
|
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<Uint8Array> => {
|
||||||
|
// 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<void> => {
|
||||||
|
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<SelectHaexCrdtLogs[]> => {
|
||||||
|
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<SelectHaexCrdtLogs>(
|
||||||
|
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<boolean> => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
525
src/stores/sync/orchestrator.ts
Normal file
525
src/stores/sync/orchestrator.ts
Normal file
@ -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<BackendSyncState>({})
|
||||||
|
|
||||||
|
// 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<SelectHaexSyncStatus | null> => {
|
||||||
|
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<SelectHaexSyncStatus>,
|
||||||
|
): Promise<void> => {
|
||||||
|
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<SelectHaexCrdtLogs[]> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
250
src/utils/crypto/vaultKey.ts
Normal file
250
src/utils/crypto/vaultKey.ts
Normal file
@ -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<CryptoKey> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
// 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<T = object>(
|
||||||
|
encryptedData: string,
|
||||||
|
nonce: string,
|
||||||
|
vaultKey: Uint8Array,
|
||||||
|
): Promise<T> {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user