"use client"; /** * Opt-in encrypted sync. The payload (decks, folders, homework — never the * API key) is encrypted in this browser with AES-GCM using a key derived from * the user's passphrase (PBKDF2-SHA256, 310k iterations). The server only * ever stores the opaque envelope under a random sync ID. * * Conflict policy: server-stamped `updatedAt` is the clock; when both sides * changed since the last sync the UI asks the user which one to keep. */ import { z } from "zod"; import { backupDataSchema } from "./backup"; import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store"; import { ApiError } from "./client-api"; import { replaceFolders } from "./folders"; import { replaceHomework } from "./homework"; import { podcastStore, replacePodcasts } from "./podcasts"; import { ensureLearningHydrated, learningStore, replaceLearning } from "./history"; import { localeHeader, t } from "./i18n"; import { authHeaders } from "./settings"; import { deckStore, replaceDecks } from "./storage"; import { homeworkStore } from "./homework"; import { folderStore } from "./folders"; import { clearDeviceKey, loadDeviceKey, saveDeviceKey } from "./sync-key"; import { newSyncId, setSyncRecord, syncStore, withoutDirtyTracking } from "./sync-state"; const KDF = "PBKDF2-SHA256"; const ITERATIONS = 310_000; export type SyncEnvelope = { v: 1; kdf: "PBKDF2-SHA256"; iterations: number; salt: string; iv: string; ciphertext: string; }; export type SyncStatus = "pushed" | "pulled" | "up-to-date" | "conflict" | "locked" | "error" | "off"; export type SyncOutcome = { status: SyncStatus; message?: string }; const syncPayloadSchema = z.object({ app: z.literal("freezmo"), kind: z.literal("sync"), version: z.literal(1), clientUpdatedAt: z.number(), data: backupDataSchema, }); type SyncPayload = z.infer; /* ------------------------------------------------------------------ */ /* Encoding helpers (chunked: payloads can be megabytes) */ /* ------------------------------------------------------------------ */ function toBase64(bytes: Uint8Array): string { let binary = ""; const chunk = 0x8000; for (let index = 0; index < bytes.length; index += chunk) { binary += String.fromCharCode(...bytes.subarray(index, index + chunk)); } return btoa(binary); } function fromBase64(value: string): Uint8Array { const binary = atob(value); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index); return bytes; } /* ------------------------------------------------------------------ */ /* Crypto */ /* ------------------------------------------------------------------ */ export async function deriveSyncKey(passphrase: string, saltBase64: string): Promise { const material = await crypto.subtle.importKey( "raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"], ); return crypto.subtle.deriveKey( { name: "PBKDF2", salt: fromBase64(saltBase64) as unknown as BufferSource, iterations: ITERATIONS, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); } async function encryptWithKey(key: CryptoKey, payload: unknown): Promise<{ iv: string; ciphertext: string }> { const iv = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(JSON.stringify(payload)); const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded); return { iv: toBase64(iv), ciphertext: toBase64(new Uint8Array(cipher)) }; } async function decryptWithKey(key: CryptoKey, ivBase64: string, ciphertextBase64: string): Promise { const plain = await crypto.subtle.decrypt( { name: "AES-GCM", iv: fromBase64(ivBase64) as unknown as BufferSource }, key, fromBase64(ciphertextBase64) as unknown as BufferSource, ); return JSON.parse(new TextDecoder().decode(plain)); } /* ------------------------------------------------------------------ */ /* Server API */ /* ------------------------------------------------------------------ */ async function apiGet(id: string): Promise<{ updatedAt: number; envelope: SyncEnvelope } | null> { let response: Response; try { response = await fetch(`/api/sync?id=${encodeURIComponent(id)}`, { cache: "no-store", headers: { ...authHeaders(), ...localeHeader() }, }); } catch { throw new ApiError(t("settings.syncError"), "network"); } if (response.status === 404) return null; if (!response.ok) { const body = (await response.json().catch(() => null)) as { error?: string } | null; throw new ApiError(body?.error ?? t("settings.syncError"), "sync"); } return (await response.json()) as { updatedAt: number; envelope: SyncEnvelope }; } async function apiPut(id: string, envelope: SyncEnvelope): Promise { let response: Response; try { response = await fetch("/api/sync", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, body: JSON.stringify({ id, envelope }), }); } catch { throw new ApiError(t("settings.syncError"), "network"); } if (!response.ok) { const body = (await response.json().catch(() => null)) as { error?: string } | null; throw new ApiError(body?.error ?? t("settings.syncError"), "sync"); } const body = (await response.json()) as { updatedAt: number }; return body.updatedAt; } async function apiDelete(id: string): Promise { const response = await fetch(`/api/sync?id=${encodeURIComponent(id)}`, { method: "DELETE", headers: { ...authHeaders(), ...localeHeader() }, }); if (!response.ok) throw new ApiError(t("settings.syncError"), "sync"); } /* ------------------------------------------------------------------ */ /* Payload */ /* ------------------------------------------------------------------ */ async function buildPayload(): Promise { await ensureLearningHydrated(); await ensureChatsHydrated(); return { app: "freezmo", kind: "sync", version: 1, clientUpdatedAt: Date.now(), data: { decks: deckStore.getSnapshot(), folders: folderStore.getSnapshot(), homework: homeworkStore.getSnapshot(), podcasts: podcastStore.getSnapshot(), history: learningStore.getAnswersSnapshot(), srs: learningStore.getSrsSnapshot(), chats: chatHistoryStore.getSnapshot(), }, }; } async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope: SyncEnvelope }): Promise { const parsed = syncPayloadSchema.parse( await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext), ); withoutDirtyTracking(() => { replaceFolders(parsed.data.folders); replaceDecks(parsed.data.decks); replaceHomework(parsed.data.homework); replacePodcasts(parsed.data.podcasts ?? []); replaceChats(parsed.data.chats ?? []); }); await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []); setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false }); } /* ------------------------------------------------------------------ */ /* Public API */ /* ------------------------------------------------------------------ */ export async function syncNow(strategy: "auto" | "push" | "pull" = "auto"): Promise { const record = syncStore.getSnapshot(); if (!record.enabled || !record.id) return { status: "off" }; const key = await loadDeviceKey(); if (!key) return { status: "locked" }; try { const remote = await apiGet(record.id); const remoteIsNewer = Boolean(remote && remote.updatedAt > (record.lastSyncedAt ?? 0)); if (strategy === "pull") { if (!remote) return { status: "error", message: t("server.syncNotFound") }; await applyRemote(key, remote); return { status: "pulled" }; } if (strategy === "auto" && remoteIsNewer) { if (record.dirtyAt) { setSyncRecord({ conflict: true }); return { status: "conflict" }; } await applyRemote(key, remote!); return { status: "pulled" }; } if (strategy === "auto" && !remoteIsNewer && !record.dirtyAt) { return { status: "up-to-date" }; } const { iv, ciphertext } = await encryptWithKey(key, await buildPayload()); const envelope: SyncEnvelope = { v: 1, kdf: KDF, iterations: ITERATIONS, salt: record.salt, iv, ciphertext, }; const updatedAt = await apiPut(record.id, envelope); setSyncRecord({ lastSyncedAt: updatedAt, dirtyAt: null, conflict: false }); return { status: "pushed" }; } catch (error) { return { status: "error", message: error instanceof ApiError ? error.message : t("settings.syncError"), }; } } /** First device: brand-new id and salt, uploads straight away. */ export async function enableSync(passphrase: string): Promise { const id = newSyncId(); const salt = toBase64(crypto.getRandomValues(new Uint8Array(16))); const key = await deriveSyncKey(passphrase, salt); await saveDeviceKey(key); setSyncRecord({ enabled: true, id, salt, lastSyncedAt: null, dirtyAt: Date.now(), conflict: false }); return syncNow("push"); } /** Same device after the browser dropped the stored key. */ export async function unlockSync(passphrase: string): Promise { const record = syncStore.getSnapshot(); if (!record.enabled || !record.id) return { status: "off" }; try { const remote = await apiGet(record.id); const salt = remote?.envelope.salt || record.salt; if (!salt) return { status: "error", message: t("settings.syncError") }; const key = await deriveSyncKey(passphrase, salt); if (remote) { try { syncPayloadSchema.parse(await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext)); } catch { return { status: "error", message: t("settings.syncWrongPass") }; } } await saveDeviceKey(key); setSyncRecord({ salt }); return syncNow("auto"); } catch (error) { return { status: "error", message: error instanceof ApiError ? error.message : t("settings.syncError"), }; } } /** New device: the sync ID plus the passphrase from the other device. */ export async function joinSync(id: string, passphrase: string): Promise { try { const remote = await apiGet(id.trim()); if (!remote) return { status: "error", message: t("server.syncNotFound") }; const key = await deriveSyncKey(passphrase, remote.envelope.salt); try { syncPayloadSchema.parse(await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext)); } catch { return { status: "error", message: t("settings.syncWrongPass") }; } await saveDeviceKey(key); setSyncRecord({ enabled: true, id: id.trim(), salt: remote.envelope.salt, lastSyncedAt: null, dirtyAt: null, conflict: false, }); await applyRemote(key, remote); return { status: "pulled" }; } catch (error) { return { status: "error", message: error instanceof ApiError ? error.message : t("settings.syncError"), }; } } export async function deleteSyncedData(): Promise { const record = syncStore.getSnapshot(); try { if (record.id) await apiDelete(record.id); } catch (error) { return { status: "error", message: error instanceof ApiError ? error.message : t("settings.syncError"), }; } await clearDeviceKey(); setSyncRecord({ enabled: false, id: "", salt: "", lastSyncedAt: null, dirtyAt: null, conflict: false }); return { status: "up-to-date" }; } export async function forgetDeviceKey(): Promise { await clearDeviceKey(); } export async function hasDeviceKey(): Promise { return Boolean(await loadDeviceKey()); }