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:
2025-11-05 17:08:49 +01:00
parent 9adee46166
commit 63849d86e1
4 changed files with 1189 additions and 0 deletions

View File

@ -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

390
src/stores/sync/engine.ts Normal file
View 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,
}
})

View 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,
}
},
)

View 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
}