12345678910111213141516171819202122import {23 DEVICE_MANIFEST_VERSION,24 type SyncMessage,25 type SignedDeviceManifest,26} from "../shared/sync-types";27import type { SyncSession } from "../shared/sync-transport";28import { SYNC_LIMITS } from "../shared/sync-limits";29import { isConnectionClosedError } from "../shared/sync-outcome";3031const SESSION_TIMEOUT_MS = 30_000;3233type SyncStage =34 | "preparing_hello"35 | "waiting_for_device_hello"36 | "building_local_manifest"37 | "waiting_for_device_manifest"38 | "waiting_for_device_request"39 | "building_requested_entries"40 | "waiting_for_device_entries"41 | "waiting_for_device_tombstones"42 | "building_device_manifest"43 | "waiting_for_device_graph"44 | "waiting_for_device_done";4546474849505152export interface SyncVaultAdapter {53 computeVaultId(): Promise<string>;54 getOurDeviceId(): Promise<string>;55 56 getLocalVersion(): Promise<number>;57 58 buildEntryManifest(): Promise<Record<string, number>>;59 60 buildTombstoneManifest(): Promise<Record<string, number>>;61 62 getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;63 64 applyEntries(entries: Record<string, string>): Promise<void>;65 66 applyTombstones(tombstones: Record<string, number>): Promise<void>;67 68697071727374757677 buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;78 7980818283 applyIncomingDeviceManifest?(84 manifest: SignedDeviceManifest,85 authenticatedRemoteStaticPublicKey: Uint8Array,86 ): Promise<void>;87}8889export interface SyncOutcome {90 status: "completed" | "vault_mismatch" | "errored" | "timeout";91 entriesReceived: number;92 entriesSent: number;93 tombstonesReceived: number;94 tombstonesSent: number;95 error?: string;96}979899100101102export async function runSyncInitiator(103 session: SyncSession,104 vault: SyncVaultAdapter,105): Promise<SyncOutcome> {106 const result: SyncOutcome = {107 status: "errored",108 entriesReceived: 0,109 entriesSent: 0,110 tombstonesReceived: 0,111 tombstonesSent: 0,112 };113114 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);115 116 117 118 119 let stage: SyncStage = "preparing_hello";120 try {121 const ourVaultId = await vault.computeVaultId();122 const ourDeviceId = await vault.getOurDeviceId();123 const localVersion = await vault.getLocalVersion();124 const localTime = Date.now();125 const supportsDeviceManifest =126 vault.buildOutgoingDeviceManifest !== undefined &&127 vault.applyIncomingDeviceManifest !== undefined;128129 await sendMessage(session, {130 type: "SYNC_HELLO",131 vaultId: ourVaultId,132 deviceId: ourDeviceId,133 version: localVersion,134 deviceTime: localTime,135 deviceManifestVersion: supportsDeviceManifest136 ? DEVICE_MANIFEST_VERSION137 : undefined,138 });139 stage = "waiting_for_device_hello";140 const helloIn = await receiveMessage(session, timer);141 if (helloIn.type !== "SYNC_HELLO") {142 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);143 }144 if (helloIn.vaultId !== ourVaultId) {145 await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });146 result.status = "vault_mismatch";147 return result;148 }149150 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {151 console.warn(`[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);152 }153154 155 156 157 158 if (typeof helloIn.version === "number" && helloIn.version === localVersion) {159 if (160 supportsDeviceManifest &&161 helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION162 ) {163 stage = "building_device_manifest";164 const ours = await vault.buildOutgoingDeviceManifest!();165 await sendMessage(session, {166 type: "SYNC_DEVICE_MANIFEST",167 manifest: ours,168 });169 stage = "waiting_for_device_graph";170 const graphIn = await receiveMessage(session, timer);171 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {172 return errorOutcome(173 result,174 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,175 );176 }177 if (graphIn.manifest) {178 await vault.applyIncomingDeviceManifest!(179 graphIn.manifest,180 session.remoteStaticPublicKey,181 );182 }183 }184 await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });185 stage = "waiting_for_device_done";186 const closing = await awaitClosingDone(session, timer);187 if (closing.error) return errorOutcome(result, closing.error);188 result.status = "completed";189 return result;190 }191192 stage = "building_local_manifest";193 const ourEntries = await vault.buildEntryManifest();194 const ourTombstones = await vault.buildTombstoneManifest();195 await sendMessage(session, {196 type: "SYNC_MANIFEST",197 entries: ourEntries,198 tombstones: ourTombstones,199 });200 stage = "waiting_for_device_manifest";201 const manifestIn = await receiveMessage(session, timer);202 if (manifestIn.type !== "SYNC_MANIFEST") {203 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);204 }205206 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);207 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });208209 stage = "waiting_for_device_request";210 const requestIn = await receiveMessage(session, timer);211 if (requestIn.type !== "SYNC_REQUEST") {212 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);213 }214 215 216 217218 stage = "building_requested_entries";219 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);220 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });221 result.entriesSent = Object.keys(ourPayload).length;222223 if (wantFromPeer.length > 0) {224 stage = "waiting_for_device_entries";225 const entriesIn = await receiveMessage(session, timer);226 if (entriesIn.type !== "SYNC_ENTRIES") {227 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);228 }229 await vault.applyEntries(entriesIn.entries);230 result.entriesReceived = Object.keys(entriesIn.entries).length;231 }232233 234 235 const newerTombstonesForPeer: Record<string, number> = {};236 for (const [id, ts] of Object.entries(ourTombstones)) {237 const peerTs = manifestIn.tombstones[id] ?? 0;238 if (ts > peerTs) newerTombstonesForPeer[id] = ts;239 }240 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });241 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;242243 stage = "waiting_for_device_tombstones";244 const tombstonesIn = await receiveMessage(session, timer);245 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {246 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);247 }248 await vault.applyTombstones(tombstonesIn.tombstones);249 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;250251 252 253 254 255 256 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {257 stage = "building_device_manifest";258 const ours = await vault.buildOutgoingDeviceManifest();259 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });260 stage = "waiting_for_device_graph";261 const manifestIn = await receiveMessage(session, timer);262 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {263 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);264 }265 if (manifestIn.manifest) {266 await vault.applyIncomingDeviceManifest(267 manifestIn.manifest,268 session.remoteStaticPublicKey,269 );270 }271 }272273 await sendMessage(session, {274 type: "SYNC_DONE",275 entriesReceived: result.entriesReceived,276 entriesSent: result.entriesSent,277 });278 stage = "waiting_for_device_done";279 const closing = await awaitClosingDone(session, timer);280 if (closing.error) return errorOutcome(result, closing.error);281 result.status = "completed";282 return result;283 } catch (err) {284 if ((err as Error).message === "sync_timeout") {285 result.status = "timeout";286 result.error = `sync_timeout:${stage}`;287 return result;288 }289 return errorOutcome(result, (err as Error).message);290 } finally {291 292 293 294 timer.cancel();295 }296}297298299300301302303export async function runSyncResponder(304 session: SyncSession,305 vault: SyncVaultAdapter,306): Promise<SyncOutcome> {307 const result: SyncOutcome = {308 status: "errored",309 entriesReceived: 0,310 entriesSent: 0,311 tombstonesReceived: 0,312 tombstonesSent: 0,313 };314 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);315 let stage: SyncStage = "waiting_for_device_hello";316 try {317 const ourVaultId = await vault.computeVaultId();318 const ourDeviceId = await vault.getOurDeviceId();319 const localVersion = await vault.getLocalVersion();320 const localTime = Date.now();321 const supportsDeviceManifest =322 vault.buildOutgoingDeviceManifest !== undefined &&323 vault.applyIncomingDeviceManifest !== undefined;324325 const helloIn = await receiveMessage(session, timer);326 if (helloIn.type !== "SYNC_HELLO") {327 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);328 }329 await sendMessage(session, {330 type: "SYNC_HELLO",331 vaultId: ourVaultId,332 deviceId: ourDeviceId,333 version: localVersion,334 deviceTime: localTime,335 deviceManifestVersion: supportsDeviceManifest336 ? DEVICE_MANIFEST_VERSION337 : undefined,338 });339 if (helloIn.vaultId !== ourVaultId) {340 result.status = "vault_mismatch";341 return result;342 }343 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {344 console.warn(345 `[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`,346 );347 }348349 if (helloIn.version === localVersion) {350 if (351 supportsDeviceManifest &&352 helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION353 ) {354 stage = "waiting_for_device_graph";355 const graphIn = await receiveMessage(session, timer);356 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {357 return errorOutcome(358 result,359 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,360 );361 }362 const ours = await vault.buildOutgoingDeviceManifest!();363 await sendMessage(session, {364 type: "SYNC_DEVICE_MANIFEST",365 manifest: ours,366 });367 if (graphIn.manifest) {368 await vault.applyIncomingDeviceManifest!(369 graphIn.manifest,370 session.remoteStaticPublicKey,371 );372 }373 }374 stage = "waiting_for_device_done";375 const done = await receiveMessage(session, timer);376 if (done.type !== "SYNC_DONE") {377 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);378 }379 await sendMessage(session, {380 type: "SYNC_DONE",381 entriesReceived: 0,382 entriesSent: 0,383 });384 result.status = "completed";385 return result;386 }387388 const ourEntries = await vault.buildEntryManifest();389 const ourTombstones = await vault.buildTombstoneManifest();390 stage = "waiting_for_device_manifest";391 const manifestIn = await receiveMessage(session, timer);392 if (manifestIn.type !== "SYNC_MANIFEST") {393 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);394 }395 await sendMessage(session, {396 type: "SYNC_MANIFEST",397 entries: ourEntries,398 tombstones: ourTombstones,399 });400401 stage = "waiting_for_device_request";402 const requestIn = await receiveMessage(session, timer);403 if (requestIn.type !== "SYNC_REQUEST") {404 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);405 }406 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);407 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });408409 stage = "waiting_for_device_entries";410 const entriesIn = await receiveMessage(session, timer);411 if (entriesIn.type !== "SYNC_ENTRIES") {412 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);413 }414 if (wantFromPeer.length > 0) {415 await vault.applyEntries(entriesIn.entries);416 result.entriesReceived = Object.keys(entriesIn.entries).length;417 }418 if (requestIn.entryIds.length > 0) {419 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);420 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });421 result.entriesSent = Object.keys(ourPayload).length;422 }423424 stage = "waiting_for_device_tombstones";425 const tombstonesIn = await receiveMessage(session, timer);426 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {427 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);428 }429 await vault.applyTombstones(tombstonesIn.tombstones);430 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;431432 const newerTombstonesForPeer: Record<string, number> = {};433 for (const [id, timestamp] of Object.entries(ourTombstones)) {434 if (timestamp > (manifestIn.tombstones[id] ?? 0)) {435 newerTombstonesForPeer[id] = timestamp;436 }437 }438 await sendMessage(session, {439 type: "SYNC_TOMBSTONES",440 tombstones: newerTombstonesForPeer,441 });442 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;443444 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {445 stage = "waiting_for_device_graph";446 const graphIn = await receiveMessage(session, timer);447 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {448 return errorOutcome(449 result,450 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,451 );452 }453 const ours = await vault.buildOutgoingDeviceManifest();454 await sendMessage(session, {455 type: "SYNC_DEVICE_MANIFEST",456 manifest: ours,457 });458 if (graphIn.manifest) {459 await vault.applyIncomingDeviceManifest(460 graphIn.manifest,461 session.remoteStaticPublicKey,462 );463 }464 }465466 stage = "waiting_for_device_done";467 const done = await receiveMessage(session, timer);468 if (done.type !== "SYNC_DONE") {469 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);470 }471 await sendMessage(session, {472 type: "SYNC_DONE",473 entriesReceived: result.entriesReceived,474 entriesSent: result.entriesSent,475 });476 result.status = "completed";477 return result;478 } catch (error) {479 if ((error as Error).message === "sync_timeout") {480 result.status = "timeout";481 result.error = `sync_timeout:${stage}`;482 return result;483 }484 return errorOutcome(result, (error as Error).message);485 } finally {486 timer.cancel();487 }488}489490491492493494495496497498499500501502503async function awaitClosingDone(504 session: SyncSession,505 timer: Timer,506): Promise<{ error?: string }> {507 try {508 const done = await receiveMessage(session, timer);509 if (done.type !== "SYNC_DONE") {510 return { error: `expected SYNC_DONE, got ${done.type}` };511 }512 return {};513 } catch (err) {514 if ((err as Error).message === "sync_timeout") throw err;515 if (isConnectionClosedError(err)) return {};516 return { error: (err as Error).message };517 }518}519520function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {521 const want: string[] = [];522 for (const [id, ts] of Object.entries(remote)) {523 const ours = local[id];524 if (ours === undefined || ts > ours) want.push(id);525 }526 return want;527}528529interface Timer {530 signal: AbortSignal;531 cancel(): void;532}533534function scheduleTimeout(ms: number): Timer {535 const ctrl = new AbortController();536 const t = setTimeout(() => ctrl.abort(new Error("sync_timeout")), ms);537 return {538 signal: ctrl.signal,539 cancel: () => clearTimeout(t),540 };541}542543async function sendMessage(session: SyncSession, msg: SyncMessage): Promise<void> {544 session.send(new TextEncoder().encode(JSON.stringify(msg)));545}546547async function receiveMessage(session: SyncSession, timer: Timer): Promise<SyncMessage> {548 const pending = session.receive();549 const aborted = new Promise<never>((_, reject) => {550 if (timer.signal.aborted) reject(new Error("sync_timeout"));551 timer.signal.addEventListener("abort", () => reject(new Error("sync_timeout")));552 });553 const bytes = await Promise.race([pending, aborted]);554 555 556 557 if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {558 throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);559 }560 const parsed = JSON.parse(new TextDecoder().decode(bytes));561 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");562 return parsed as SyncMessage;563}564565function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {566 outcome.status = "errored";567 outcome.error = error;568 return outcome;569}