12345678910111213141516171819202122import {23 DEVICE_MANIFEST_VERSION,24 SYNC_PROTOCOL_VERSION,25 type SyncMessage,26 type SignedDeviceManifest,27} from "../shared/sync-types";28import type { SyncSession } from "../shared/sync-transport";29import { SYNC_LIMITS, validateSyncManifestCounts } from "../shared/sync-limits";30import { isConnectionClosedError } from "../shared/sync-outcome";3132const SESSION_TIMEOUT_MS = 30_000;3334type SyncStage =35 | "preparing_hello"36 | "waiting_for_device_hello"37 | "building_local_manifest"38 | "waiting_for_device_manifest"39 | "waiting_for_device_request"40 | "building_requested_entries"41 | "waiting_for_device_entries"42 | "waiting_for_device_tombstones"43 | "building_device_manifest"44 | "waiting_for_device_graph"45 | "waiting_for_device_done";4647484950515253export interface SyncVaultAdapter {54 computeVaultId(): Promise<string>;55 getOurDeviceId(): Promise<string>;56 57 getLocalVersion(): Promise<number>;58 59 buildEntryManifest(): Promise<Record<string, number>>;60 61 buildTombstoneManifest(): Promise<Record<string, number>>;62 63 getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;64 65 applyEntries(66 entries: Record<string, string>,67 remoteManifest?: Record<string, number>,68 ): Promise<void>;69 70 applyTombstones(tombstones: Record<string, number>): Promise<void>;71 72737475767778798081 buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;82 8384858687 applyIncomingDeviceManifest?(88 manifest: SignedDeviceManifest,89 authenticatedRemoteStaticPublicKey: Uint8Array,90 ): Promise<void>;91}9293export interface SyncOutcome {94 status: "completed" | "vault_mismatch" | "errored" | "timeout";95 entriesReceived: number;96 entriesSent: number;97 tombstonesReceived: number;98 tombstonesSent: number;99 error?: string;100}101102103104105106export async function runSyncInitiator(107 session: SyncSession,108 vault: SyncVaultAdapter,109): Promise<SyncOutcome> {110 const result: SyncOutcome = {111 status: "errored",112 entriesReceived: 0,113 entriesSent: 0,114 tombstonesReceived: 0,115 tombstonesSent: 0,116 };117118 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);119 120 121 122 123 let stage: SyncStage = "preparing_hello";124 try {125 const ourVaultId = await vault.computeVaultId();126 const ourDeviceId = await vault.getOurDeviceId();127 const localVersion = await vault.getLocalVersion();128 const localTime = Date.now();129 const supportsDeviceManifest =130 vault.buildOutgoingDeviceManifest !== undefined &&131 vault.applyIncomingDeviceManifest !== undefined;132133 await sendMessage(session, {134 type: "SYNC_HELLO",135 vaultId: ourVaultId,136 deviceId: ourDeviceId,137 version: localVersion,138 deviceTime: localTime,139 protocolVersion: SYNC_PROTOCOL_VERSION,140 deviceManifestVersion: supportsDeviceManifest141 ? DEVICE_MANIFEST_VERSION142 : undefined,143 });144 stage = "waiting_for_device_hello";145 const helloIn = await receiveMessage(session, timer);146 if (helloIn.type !== "SYNC_HELLO") {147 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);148 }149 if ((helloIn.protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION) {150 await sendMessage(session, { type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });151 return errorOutcome(result, "PEER_PROTOCOL_OUTDATED");152 }153 if (helloIn.vaultId !== ourVaultId) {154 await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });155 result.status = "vault_mismatch";156 return result;157 }158159 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {160 console.warn(`[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);161 }162163 const exchangeDeviceManifest =164 supportsDeviceManifest &&165 helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION;166167 stage = "building_local_manifest";168 const ourEntries = await vault.buildEntryManifest();169 const ourTombstones = await vault.buildTombstoneManifest();170 await sendMessage(session, {171 type: "SYNC_MANIFEST",172 entries: ourEntries,173 tombstones: ourTombstones,174 });175 stage = "waiting_for_device_manifest";176 const manifestIn = await receiveMessage(session, timer);177 if (manifestIn.type !== "SYNC_MANIFEST") {178 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);179 }180 validateSyncManifestCounts(manifestIn.entries, manifestIn.tombstones);181182 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);183 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });184185 stage = "waiting_for_device_request";186 const requestIn = await receiveMessage(session, timer);187 if (requestIn.type !== "SYNC_REQUEST") {188 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);189 }190 if (requestIn.entryIds.length > SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT) {191 return errorOutcome(result, "sync request entry count exceeds limit");192 }193 194 195 196197 stage = "building_requested_entries";198 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);199 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });200 result.entriesSent = Object.keys(ourPayload).length;201202 if (wantFromPeer.length > 0) {203 stage = "waiting_for_device_entries";204 const entriesIn = await receiveMessage(session, timer);205 if (entriesIn.type !== "SYNC_ENTRIES") {206 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);207 }208 await vault.applyEntries(entriesIn.entries, manifestIn.entries);209 result.entriesReceived = Object.keys(entriesIn.entries).length;210 }211212 213 214 const newerTombstonesForPeer: Record<string, number> = {};215 for (const [id, ts] of Object.entries(ourTombstones)) {216 const peerTs = manifestIn.tombstones[id] ?? 0;217 if (ts > peerTs) newerTombstonesForPeer[id] = ts;218 }219 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });220 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;221222 stage = "waiting_for_device_tombstones";223 const tombstonesIn = await receiveMessage(session, timer);224 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {225 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);226 }227 await vault.applyTombstones(tombstonesIn.tombstones);228 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;229230 231 232 233 234 235 if (exchangeDeviceManifest) {236 stage = "building_device_manifest";237 const ours = await vault.buildOutgoingDeviceManifest!();238 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });239 stage = "waiting_for_device_graph";240 const manifestIn = await receiveMessage(session, timer);241 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {242 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);243 }244 if (manifestIn.manifest) {245 await vault.applyIncomingDeviceManifest!(246 manifestIn.manifest,247 session.remoteStaticPublicKey,248 );249 }250 }251252 await sendMessage(session, {253 type: "SYNC_DONE",254 entriesReceived: result.entriesReceived,255 entriesSent: result.entriesSent,256 });257 stage = "waiting_for_device_done";258 const closing = await awaitClosingDone(session, timer);259 if (closing.error) return errorOutcome(result, closing.error);260 result.status = "completed";261 return result;262 } catch (err) {263 if ((err as Error).message === "sync_timeout") {264 result.status = "timeout";265 result.error = `sync_timeout:${stage}`;266 return result;267 }268 return errorOutcome(result, (err as Error).message);269 } finally {270 271 272 273 timer.cancel();274 }275}276277278279280281282export async function runSyncResponder(283 session: SyncSession,284 vault: SyncVaultAdapter,285): Promise<SyncOutcome> {286 const result: SyncOutcome = {287 status: "errored",288 entriesReceived: 0,289 entriesSent: 0,290 tombstonesReceived: 0,291 tombstonesSent: 0,292 };293 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);294 let stage: SyncStage = "waiting_for_device_hello";295 try {296 const ourVaultId = await vault.computeVaultId();297 const ourDeviceId = await vault.getOurDeviceId();298 const localVersion = await vault.getLocalVersion();299 const localTime = Date.now();300 const supportsDeviceManifest =301 vault.buildOutgoingDeviceManifest !== undefined &&302 vault.applyIncomingDeviceManifest !== undefined;303304 const helloIn = await receiveMessage(session, timer);305 if (helloIn.type !== "SYNC_HELLO") {306 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);307 }308 if ((helloIn.protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION) {309 await sendMessage(session, { type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });310 return errorOutcome(result, "PEER_PROTOCOL_OUTDATED");311 }312 await sendMessage(session, {313 type: "SYNC_HELLO",314 vaultId: ourVaultId,315 deviceId: ourDeviceId,316 version: localVersion,317 deviceTime: localTime,318 protocolVersion: SYNC_PROTOCOL_VERSION,319 deviceManifestVersion: supportsDeviceManifest320 ? DEVICE_MANIFEST_VERSION321 : undefined,322 });323 if (helloIn.vaultId !== ourVaultId) {324 result.status = "vault_mismatch";325 return result;326 }327 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {328 console.warn(329 `[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`,330 );331 }332333 const exchangeDeviceManifest =334 supportsDeviceManifest &&335 helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION;336337 const ourEntries = await vault.buildEntryManifest();338 const ourTombstones = await vault.buildTombstoneManifest();339 stage = "waiting_for_device_manifest";340 const manifestIn = await receiveMessage(session, timer);341 if (manifestIn.type !== "SYNC_MANIFEST") {342 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);343 }344 validateSyncManifestCounts(manifestIn.entries, manifestIn.tombstones);345 await sendMessage(session, {346 type: "SYNC_MANIFEST",347 entries: ourEntries,348 tombstones: ourTombstones,349 });350351 stage = "waiting_for_device_request";352 const requestIn = await receiveMessage(session, timer);353 if (requestIn.type !== "SYNC_REQUEST") {354 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);355 }356 if (requestIn.entryIds.length > SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT) {357 return errorOutcome(result, "sync request entry count exceeds limit");358 }359 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);360 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });361362 stage = "waiting_for_device_entries";363 const entriesIn = await receiveMessage(session, timer);364 if (entriesIn.type !== "SYNC_ENTRIES") {365 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);366 }367 if (wantFromPeer.length > 0) {368 await vault.applyEntries(entriesIn.entries, manifestIn.entries);369 result.entriesReceived = Object.keys(entriesIn.entries).length;370 }371 if (requestIn.entryIds.length > 0) {372 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);373 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });374 result.entriesSent = Object.keys(ourPayload).length;375 }376377 stage = "waiting_for_device_tombstones";378 const tombstonesIn = await receiveMessage(session, timer);379 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {380 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);381 }382 await vault.applyTombstones(tombstonesIn.tombstones);383 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;384385 const newerTombstonesForPeer: Record<string, number> = {};386 for (const [id, timestamp] of Object.entries(ourTombstones)) {387 if (timestamp > (manifestIn.tombstones[id] ?? 0)) {388 newerTombstonesForPeer[id] = timestamp;389 }390 }391 await sendMessage(session, {392 type: "SYNC_TOMBSTONES",393 tombstones: newerTombstonesForPeer,394 });395 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;396397 if (exchangeDeviceManifest) {398 stage = "waiting_for_device_graph";399 const graphIn = await receiveMessage(session, timer);400 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {401 return errorOutcome(402 result,403 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,404 );405 }406 const ours = await vault.buildOutgoingDeviceManifest!();407 await sendMessage(session, {408 type: "SYNC_DEVICE_MANIFEST",409 manifest: ours,410 });411 if (graphIn.manifest) {412 await vault.applyIncomingDeviceManifest!(413 graphIn.manifest,414 session.remoteStaticPublicKey,415 );416 }417 }418419 stage = "waiting_for_device_done";420 const done = await receiveMessage(session, timer);421 if (done.type !== "SYNC_DONE") {422 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);423 }424 await sendMessage(session, {425 type: "SYNC_DONE",426 entriesReceived: result.entriesReceived,427 entriesSent: result.entriesSent,428 });429 result.status = "completed";430 return result;431 } catch (error) {432 if ((error as Error).message === "sync_timeout") {433 result.status = "timeout";434 result.error = `sync_timeout:${stage}`;435 return result;436 }437 return errorOutcome(result, (error as Error).message);438 } finally {439 timer.cancel();440 }441}442443444445446447448449450451452453454455456async function awaitClosingDone(457 session: SyncSession,458 timer: Timer,459): Promise<{ error?: string }> {460 try {461 const done = await receiveMessage(session, timer);462 if (done.type !== "SYNC_DONE") {463 return { error: `expected SYNC_DONE, got ${done.type}` };464 }465 return {};466 } catch (err) {467 if ((err as Error).message === "sync_timeout") throw err;468 if (isConnectionClosedError(err)) return {};469 return { error: (err as Error).message };470 }471}472473function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {474 const want: string[] = [];475 for (const [id, ts] of Object.entries(remote)) {476 const ours = local[id];477 if (ours === undefined || ts > ours) want.push(id);478 }479 return want;480}481482interface Timer {483 signal: AbortSignal;484 cancel(): void;485}486487function scheduleTimeout(ms: number): Timer {488 const ctrl = new AbortController();489 const t = setTimeout(() => ctrl.abort(new Error("sync_timeout")), ms);490 return {491 signal: ctrl.signal,492 cancel: () => clearTimeout(t),493 };494}495496async function sendMessage(session: SyncSession, msg: SyncMessage): Promise<void> {497 session.send(new TextEncoder().encode(JSON.stringify(msg)));498}499500async function receiveMessage(session: SyncSession, timer: Timer): Promise<SyncMessage> {501 const pending = session.receive();502 const aborted = new Promise<never>((_, reject) => {503 if (timer.signal.aborted) reject(new Error("sync_timeout"));504 timer.signal.addEventListener("abort", () => reject(new Error("sync_timeout")));505 });506 const bytes = await Promise.race([pending, aborted]);507 508 509 510 if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {511 throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);512 }513 const parsed = JSON.parse(new TextDecoder().decode(bytes));514 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");515 return parsed as SyncMessage;516}517518function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {519 outcome.status = "errored";520 outcome.error = error;521 return outcome;522}