// Vault sync — Phase 2 manifest-diff coordinator. // // After reconnect Noise KK is open and PAIR_IDENTITY has been exchanged // (`shared/sync-transport.ts`), this coordinator drives the manifest exchange: // // -> SYNC_HELLO { vaultId, deviceId, deviceManifestVersion? } // <- SYNC_HELLO { vaultId, deviceId, deviceManifestVersion? } // (mismatch → SYNC_ERROR) // <-> SYNC_MANIFEST { entries, tombstones } (each side sends one) // -> SYNC_REQUEST { entryIds } (entries newer on the peer) // <- SYNC_ENTRIES { entries } // <- SYNC_REQUEST { entryIds } (entries newer on us) // -> SYNC_ENTRIES { entries } // <-> SYNC_TOMBSTONES { tombstones } // <-> SYNC_DEVICE_MANIFEST (also runs on equal versions) // <-> SYNC_DONE // // Entries are opaque base64 ciphertext — the coordinator never decrypts. // Last-write-wins at the entry level by mtime; tombstones override updates // with a later deletion timestamp. import { DEVICE_MANIFEST_VERSION, type SyncMessage, type SignedDeviceManifest, } from "../shared/sync-types"; import type { SyncSession } from "../shared/sync-transport"; import { SYNC_LIMITS } from "../shared/sync-limits"; import { isConnectionClosedError } from "../shared/sync-outcome"; const SESSION_TIMEOUT_MS = 30_000; type SyncStage = | "preparing_hello" | "waiting_for_device_hello" | "building_local_manifest" | "waiting_for_device_manifest" | "waiting_for_device_request" | "building_requested_entries" | "waiting_for_device_entries" | "waiting_for_device_tombstones" | "building_device_manifest" | "waiting_for_device_graph" | "waiting_for_device_done"; /** * Vault-side adapter — the coordinator's dependency on the vault layer. * The vault holds the canonical entry/tombstone tables; the coordinator * pulls a manifest, requests entries by id, applies remote entries, and * applies remote tombstones. */ export interface SyncVaultAdapter { computeVaultId(): Promise; getOurDeviceId(): Promise; /** Returns the vault's monotonic version (`peach_last_modified_at`). */ getLocalVersion(): Promise; /** Returns the per-entry mtimes for our active entries. */ buildEntryManifest(): Promise>; /** Returns the per-entry deletion timestamps for our tombstones. */ buildTombstoneManifest(): Promise>; /** Returns base64 ciphertext for each requested entry id. */ getEncryptedEntries(entryIds: string[]): Promise>; /** Apply incoming encrypted entries; vault decides per-entry overwrite. */ applyEntries( entries: Record, remoteManifest?: Record, ): Promise; /** Apply incoming tombstones; vault retains the latest deletion ts. */ applyTombstones(tombstones: Record): Promise; /** * Optional — v2 brief §4.3 device manifest exchange. The extension (as * the trust anchor for the device graph) returns a signed manifest * enumerating every paired device it knows about; the peer verifies it * against the extension's known Ed25519 signing pubkey and upserts any * newly-vouched devices into its own registry. * * When this method is undefined, the coordinator skips the manifest * step entirely. Both ends must opt in for the exchange to occur. */ buildOutgoingDeviceManifest?(): Promise; /** * Handle a manifest received from the peer. The transport-authenticated * Noise static key is supplied separately so a self-asserted issuer id * cannot select a different paired-device verification key. */ applyIncomingDeviceManifest?( manifest: SignedDeviceManifest, authenticatedRemoteStaticPublicKey: Uint8Array, ): Promise; } export interface SyncOutcome { status: "completed" | "vault_mismatch" | "errored" | "timeout"; entriesReceived: number; entriesSent: number; tombstonesReceived: number; tombstonesSent: number; error?: string; } /** * Run one sync round as the lower-key initiator. Device type does not decide * the role; the higher-key peer runs [runSyncResponder]. */ export async function runSyncInitiator( session: SyncSession, vault: SyncVaultAdapter, ): Promise { const result: SyncOutcome = { status: "errored", entriesReceived: 0, entriesSent: 0, tombstonesReceived: 0, tombstonesSent: 0, }; const timer = scheduleTimeout(SESSION_TIMEOUT_MS); // Privacy-safe progress only: stages contain no device identifiers, relay // capabilities, keys, entry ids/counts, or vault content. Keeping the last // stage turns an otherwise opaque 30-second timeout into an actionable // cross-device diagnostic. let stage: SyncStage = "preparing_hello"; try { const ourVaultId = await vault.computeVaultId(); const ourDeviceId = await vault.getOurDeviceId(); const localVersion = await vault.getLocalVersion(); const localTime = Date.now(); const supportsDeviceManifest = vault.buildOutgoingDeviceManifest !== undefined && vault.applyIncomingDeviceManifest !== undefined; await sendMessage(session, { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: ourDeviceId, version: localVersion, deviceTime: localTime, deviceManifestVersion: supportsDeviceManifest ? DEVICE_MANIFEST_VERSION : undefined, }); stage = "waiting_for_device_hello"; const helloIn = await receiveMessage(session, timer); if (helloIn.type !== "SYNC_HELLO") { return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`); } if (helloIn.vaultId !== ourVaultId) { await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" }); result.status = "vault_mismatch"; return result; } if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) { console.warn(`[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`); } // Equal entry versions do not imply equal device graphs: paired-device // introductions and revocations live outside the vault version counter. // Exchange the signed graph when both peers advertise support, while an // older peer that omits the capability retains the legacy DONE fast path. if (typeof helloIn.version === "number" && helloIn.version === localVersion) { if ( supportsDeviceManifest && helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION ) { stage = "building_device_manifest"; const ours = await vault.buildOutgoingDeviceManifest!(); await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours, }); stage = "waiting_for_device_graph"; const graphIn = await receiveMessage(session, timer); if (graphIn.type !== "SYNC_DEVICE_MANIFEST") { return errorOutcome( result, `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`, ); } if (graphIn.manifest) { await vault.applyIncomingDeviceManifest!( graphIn.manifest, session.remoteStaticPublicKey, ); } } await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 }); stage = "waiting_for_device_done"; const closing = await awaitClosingDone(session, timer); if (closing.error) return errorOutcome(result, closing.error); result.status = "completed"; return result; } stage = "building_local_manifest"; const ourEntries = await vault.buildEntryManifest(); const ourTombstones = await vault.buildTombstoneManifest(); await sendMessage(session, { type: "SYNC_MANIFEST", entries: ourEntries, tombstones: ourTombstones, }); stage = "waiting_for_device_manifest"; const manifestIn = await receiveMessage(session, timer); if (manifestIn.type !== "SYNC_MANIFEST") { return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`); } const wantFromPeer = diffNewer(manifestIn.entries, ourEntries); await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer }); stage = "waiting_for_device_request"; const requestIn = await receiveMessage(session, timer); if (requestIn.type !== "SYNC_REQUEST") { return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`); } // Sanity: the peer's request must be a subset of what its manifest // declared as newer. A malformed/over-eager request is a protocol // violation but not a data-safety problem — we just send what they ask. stage = "building_requested_entries"; const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds); await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload }); result.entriesSent = Object.keys(ourPayload).length; if (wantFromPeer.length > 0) { stage = "waiting_for_device_entries"; const entriesIn = await receiveMessage(session, timer); if (entriesIn.type !== "SYNC_ENTRIES") { return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`); } await vault.applyEntries(entriesIn.entries, manifestIn.entries); result.entriesReceived = Object.keys(entriesIn.entries).length; } // Tombstones: each side ships the tombstones that the other does not // yet have a newer record for. const newerTombstonesForPeer: Record = {}; for (const [id, ts] of Object.entries(ourTombstones)) { const peerTs = manifestIn.tombstones[id] ?? 0; if (ts > peerTs) newerTombstonesForPeer[id] = ts; } await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer }); result.tombstonesSent = Object.keys(newerTombstonesForPeer).length; stage = "waiting_for_device_tombstones"; const tombstonesIn = await receiveMessage(session, timer); if (tombstonesIn.type !== "SYNC_TOMBSTONES") { return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`); } await vault.applyTombstones(tombstonesIn.tombstones); result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length; // v2 brief §5.6 / §4.3 — device manifest exchange. Optional: both ends // must expose the adapter methods. Disabling on either side cleanly // bypasses the step. The exchange is symmetric on the wire; the // production adapter accepts only a directly paired issuer whose stored // long-term key matches the Noise-authenticated remote static key. if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) { stage = "building_device_manifest"; const ours = await vault.buildOutgoingDeviceManifest(); await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours }); stage = "waiting_for_device_graph"; const manifestIn = await receiveMessage(session, timer); if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") { return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`); } if (manifestIn.manifest) { await vault.applyIncomingDeviceManifest( manifestIn.manifest, session.remoteStaticPublicKey, ); } } await sendMessage(session, { type: "SYNC_DONE", entriesReceived: result.entriesReceived, entriesSent: result.entriesSent, }); stage = "waiting_for_device_done"; const closing = await awaitClosingDone(session, timer); if (closing.error) return errorOutcome(result, closing.error); result.status = "completed"; return result; } catch (err) { if ((err as Error).message === "sync_timeout") { result.status = "timeout"; result.error = `sync_timeout:${stage}`; return result; } return errorOutcome(result, (err as Error).message); } finally { // Session lifetime is owned by the runner (sync-runner.ts), which // closes it in its own finally block. The coordinator must not close it // here — the runner still needs to record the outcome after we return. timer.cancel(); } } /** * Exact complement of [runSyncInitiator] for the higher-key peer holding a * relay waiter room. Keeping this in the browser lets two extensions form a * direct mesh edge; the device that introduced them is not in the data path. */ export async function runSyncResponder( session: SyncSession, vault: SyncVaultAdapter, ): Promise { const result: SyncOutcome = { status: "errored", entriesReceived: 0, entriesSent: 0, tombstonesReceived: 0, tombstonesSent: 0, }; const timer = scheduleTimeout(SESSION_TIMEOUT_MS); let stage: SyncStage = "waiting_for_device_hello"; try { const ourVaultId = await vault.computeVaultId(); const ourDeviceId = await vault.getOurDeviceId(); const localVersion = await vault.getLocalVersion(); const localTime = Date.now(); const supportsDeviceManifest = vault.buildOutgoingDeviceManifest !== undefined && vault.applyIncomingDeviceManifest !== undefined; const helloIn = await receiveMessage(session, timer); if (helloIn.type !== "SYNC_HELLO") { return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`); } await sendMessage(session, { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: ourDeviceId, version: localVersion, deviceTime: localTime, deviceManifestVersion: supportsDeviceManifest ? DEVICE_MANIFEST_VERSION : undefined, }); if (helloIn.vaultId !== ourVaultId) { result.status = "vault_mismatch"; return result; } if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) { console.warn( `[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`, ); } if (helloIn.version === localVersion) { if ( supportsDeviceManifest && helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION ) { stage = "waiting_for_device_graph"; const graphIn = await receiveMessage(session, timer); if (graphIn.type !== "SYNC_DEVICE_MANIFEST") { return errorOutcome( result, `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`, ); } const ours = await vault.buildOutgoingDeviceManifest!(); await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours, }); if (graphIn.manifest) { await vault.applyIncomingDeviceManifest!( graphIn.manifest, session.remoteStaticPublicKey, ); } } stage = "waiting_for_device_done"; const done = await receiveMessage(session, timer); if (done.type !== "SYNC_DONE") { return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`); } await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0, }); result.status = "completed"; return result; } const ourEntries = await vault.buildEntryManifest(); const ourTombstones = await vault.buildTombstoneManifest(); stage = "waiting_for_device_manifest"; const manifestIn = await receiveMessage(session, timer); if (manifestIn.type !== "SYNC_MANIFEST") { return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`); } await sendMessage(session, { type: "SYNC_MANIFEST", entries: ourEntries, tombstones: ourTombstones, }); stage = "waiting_for_device_request"; const requestIn = await receiveMessage(session, timer); if (requestIn.type !== "SYNC_REQUEST") { return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`); } const wantFromPeer = diffNewer(manifestIn.entries, ourEntries); await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer }); stage = "waiting_for_device_entries"; const entriesIn = await receiveMessage(session, timer); if (entriesIn.type !== "SYNC_ENTRIES") { return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`); } if (wantFromPeer.length > 0) { await vault.applyEntries(entriesIn.entries, manifestIn.entries); result.entriesReceived = Object.keys(entriesIn.entries).length; } if (requestIn.entryIds.length > 0) { const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds); await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload }); result.entriesSent = Object.keys(ourPayload).length; } stage = "waiting_for_device_tombstones"; const tombstonesIn = await receiveMessage(session, timer); if (tombstonesIn.type !== "SYNC_TOMBSTONES") { return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`); } await vault.applyTombstones(tombstonesIn.tombstones); result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length; const newerTombstonesForPeer: Record = {}; for (const [id, timestamp] of Object.entries(ourTombstones)) { if (timestamp > (manifestIn.tombstones[id] ?? 0)) { newerTombstonesForPeer[id] = timestamp; } } await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer, }); result.tombstonesSent = Object.keys(newerTombstonesForPeer).length; if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) { stage = "waiting_for_device_graph"; const graphIn = await receiveMessage(session, timer); if (graphIn.type !== "SYNC_DEVICE_MANIFEST") { return errorOutcome( result, `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`, ); } const ours = await vault.buildOutgoingDeviceManifest(); await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours, }); if (graphIn.manifest) { await vault.applyIncomingDeviceManifest( graphIn.manifest, session.remoteStaticPublicKey, ); } } stage = "waiting_for_device_done"; const done = await receiveMessage(session, timer); if (done.type !== "SYNC_DONE") { return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`); } await sendMessage(session, { type: "SYNC_DONE", entriesReceived: result.entriesReceived, entriesSent: result.entriesSent, }); result.status = "completed"; return result; } catch (error) { if ((error as Error).message === "sync_timeout") { result.status = "timeout"; result.error = `sync_timeout:${stage}`; return result; } return errorOutcome(result, (error as Error).message); } finally { timer.cancel(); } } /** * Wait for the peer's closing SYNC_DONE echo, treating a hang-up as success. * * By the time we send our own SYNC_DONE, every entry and tombstone has already * been exchanged and applied on both sides — the echo is a courtesy, not a * commit. Android closes the socket as soon as it has our DONE, so the parked * receive() rejects with "WebSocket closed"; counting that as `run_failed` * reported a red indicator for syncs that had fully succeeded and whose data * had demonstrably landed on the peer. * * A wrong *message* here is still a real protocol error — only a closed * transport is forgiven. */ async function awaitClosingDone( session: SyncSession, timer: Timer, ): Promise<{ error?: string }> { try { const done = await receiveMessage(session, timer); if (done.type !== "SYNC_DONE") { return { error: `expected SYNC_DONE, got ${done.type}` }; } return {}; } catch (err) { if ((err as Error).message === "sync_timeout") throw err; if (isConnectionClosedError(err)) return {}; return { error: (err as Error).message }; } } function diffNewer(remote: Record, local: Record): string[] { const want: string[] = []; for (const [id, ts] of Object.entries(remote)) { const ours = local[id]; if (ours === undefined || ts > ours) want.push(id); } return want; } interface Timer { signal: AbortSignal; cancel(): void; } function scheduleTimeout(ms: number): Timer { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(new Error("sync_timeout")), ms); return { signal: ctrl.signal, cancel: () => clearTimeout(t), }; } async function sendMessage(session: SyncSession, msg: SyncMessage): Promise { session.send(new TextEncoder().encode(JSON.stringify(msg))); } async function receiveMessage(session: SyncSession, timer: Timer): Promise { const pending = session.receive(); const aborted = new Promise((_, reject) => { if (timer.signal.aborted) reject(new Error("sync_timeout")); timer.signal.addEventListener("abort", () => reject(new Error("sync_timeout"))); }); const bytes = await Promise.race([pending, aborted]); // MV-3: decrypted plaintext can exceed the wire frame (and a non-WS // transport may not have enforced anything) — re-check before JSON.parse // materialises a multi-GB string/object graph. if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) { throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`); } const parsed = JSON.parse(new TextDecoder().decode(bytes)); if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage"); return parsed as SyncMessage; } function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome { outcome.status = "errored"; outcome.error = error; return outcome; }