/** * Blob sync session coordinator. * * Replaces the per-entry SYNC_HELLO/MANIFEST/REQUEST/ENTRIES/DONE state machine * with a single bidirectional vault-blob exchange. The encrypted vault blob is * already AES-256-GCM ciphertext under the user's master password, so the * Noise NK transport adds a second layer and the sync layer never sees * plaintext. * * Last-write-wins at the vault level: an incoming blob is applied only if its * `sent_at` is strictly newer than the local `peach_last_sync_at`. Vault * history snapshots before every apply so the user can restore an older state * if a sync overwrites the wrong vault. */ import { storageGet, storageSet } from "../shared/storage"; import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire"; import type { SyncMessage } from "../shared/sync-types"; import { takeVaultSnapshot } from "./vault"; const LAST_SYNC_KEY = "peach_last_sync_at"; const SYNC_TIMEOUT_MS = 30_000; export interface SyncCoordinatorDeps { isPro: () => Promise; isUnlocked: () => boolean; getVaultBlobs: () => Promise<{ vault_a: string; vault_b: string }>; } export interface SyncSessionTransport { send: (frame: string) => void | Promise; onFrame: (handler: (frame: string) => void) => () => void; onError: (handler: (message: string) => void) => () => void; onClose: (handler: () => void) => () => void; close: () => void; } export type SyncRole = "initiator" | "responder"; export interface SyncSessionResult { status: "completed" | "errored" | "closed"; error?: string; } /** * Persist an incoming vault blob if its `sent_at` beats the local last-sync * timestamp. Returns 'applied' when the blob was written, 'ignored' when the * incoming was older. Always snapshots BEFORE writing so the user can roll * back via vault history if a sync overwrites the wrong state. */ export async function applyIncomingVault( incomingVaultA: string, incomingVaultB: string, sentAt: number, ): Promise<"applied" | "ignored"> { const lastRaw = await storageGet(LAST_SYNC_KEY); const lastTs = lastRaw ? Number.parseInt(lastRaw, 10) : 0; if (!Number.isFinite(sentAt) || sentAt <= lastTs) { return "ignored"; } // History capture happens BEFORE the write. Failures are swallowed inside // takeVaultSnapshot — sync must never block on history. await takeVaultSnapshot("sync_received"); await storageSet("peach_vault_a", incomingVaultA); await storageSet("peach_vault_b", incomingVaultB); await storageSet(LAST_SYNC_KEY, String(sentAt)); return "applied"; } /** * Run one bidirectional sync session over an authenticated transport. * * Wire flow: * 1. Initiator sends SYNC_VAULT_SEND with its blobs + sent_at * 2. Responder receives, applies if newer, replies with SYNC_VAULT_ACK * 3. Responder then sends ITS own SYNC_VAULT_SEND so the initiator gets * whichever vault is newer * 4. Initiator receives, applies if newer, replies with SYNC_VAULT_ACK * 5. Both sides close * * Either side can short-circuit with SYNC_ERROR (pro_required, vault_locked, * timeout). The session times out after 30 seconds total. */ export async function runBlobSyncSession( role: SyncRole, transport: SyncSessionTransport, deps: SyncCoordinatorDeps, ): Promise { if (!(await deps.isPro())) { await transport.send( encodeSyncMessage({ type: "SYNC_ERROR", reason: "pro_required" }), ); transport.close(); return { status: "errored", error: "pro_required" }; } if (!deps.isUnlocked()) { await transport.send( encodeSyncMessage({ type: "SYNC_ERROR", reason: "vault_locked" }), ); transport.close(); return { status: "errored", error: "vault_locked" }; } return new Promise((resolve) => { let settled = false; const unsubscribers: Array<() => void> = []; const finish = (result: SyncSessionResult): void => { if (settled) return; settled = true; clearTimeout(timeoutHandle); for (const fn of unsubscribers) { try { fn(); } catch { // unsubscribe must not throw } } try { transport.close(); } catch { // close must not throw } resolve(result); }; const timeoutHandle = setTimeout( () => finish({ status: "errored", error: "timeout" }), SYNC_TIMEOUT_MS, ); unsubscribers.push(transport.onClose(() => finish({ status: "closed" }))); unsubscribers.push( transport.onError((message) => finish({ status: "errored", error: message || "transport_error" }), ), ); unsubscribers.push( transport.onFrame((frame) => { void (async () => { let msg: SyncMessage; try { msg = decodeSyncMessage(frame); } catch { await transport.send( encodeSyncMessage({ type: "SYNC_ERROR", reason: "invalid_frame" }), ); finish({ status: "errored", error: "invalid_frame" }); return; } if (msg.type === "SYNC_VAULT_SEND") { try { await applyIncomingVault(msg.vault_a, msg.vault_b, msg.sent_at); } catch (err) { const reason = err instanceof Error ? err.message : "apply_failed"; await transport.send( encodeSyncMessage({ type: "SYNC_ERROR", reason }), ); finish({ status: "errored", error: reason }); return; } await transport.send( encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now(), }), ); if (role === "responder") { // Send our blob back so the initiator ends up with whichever is newer. const blobs = await deps.getVaultBlobs(); await transport.send( encodeSyncMessage({ type: "SYNC_VAULT_SEND", vault_a: blobs.vault_a, vault_b: blobs.vault_b, sent_at: Date.now(), }), ); // Now wait for initiator's ACK. } else { // Initiator received responder's vault and ACK'd — done. finish({ status: "completed" }); } } else if (msg.type === "SYNC_VAULT_ACK") { // Peer received our vault. If we are the responder we still need // to wait for the initiator's ACK after we send our blob back; in // practice that flow ends via the initiator's SYNC_VAULT_SEND // branch above. The simple case: we sent first, peer ACK'd, we // can close. if (role === "initiator") { // Wait — initiator only completes after receiving responder's // SYNC_VAULT_SEND. A naked ACK without the responder vault means // the responder chose not to send anything back; treat as done. finish({ status: "completed" }); } else { finish({ status: "completed" }); } } else if (msg.type === "SYNC_ERROR") { finish({ status: "errored", error: msg.reason }); } // PAIR_* messages are out-of-band for the data-sync session. })().catch((err) => { finish({ status: "errored", error: err instanceof Error ? err.message : String(err), }); }); }), ); if (role === "initiator") { void (async () => { try { const blobs = await deps.getVaultBlobs(); await transport.send( encodeSyncMessage({ type: "SYNC_VAULT_SEND", vault_a: blobs.vault_a, vault_b: blobs.vault_b, sent_at: Date.now(), }), ); } catch (err) { finish({ status: "errored", error: err instanceof Error ? err.message : String(err), }); } })(); } }); }