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(65 entries: Record<string, string>,66 remoteManifest?: Record<string, number>,67 ): Promise<void>;68 69 applyTombstones(tombstones: Record<string, number>): Promise<void>;70 71727374757677787980 buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;81 8283848586 applyIncomingDeviceManifest?(87 manifest: SignedDeviceManifest,88 authenticatedRemoteStaticPublicKey: Uint8Array,89 ): Promise<void>;90}9192export interface SyncOutcome {93 status: "completed" | "vault_mismatch" | "errored" | "timeout";94 entriesReceived: number;95 entriesSent: number;96 tombstonesReceived: number;97 tombstonesSent: number;98 error?: string;99}100101102103104105export async function runSyncInitiator(106 session: SyncSession,107 vault: SyncVaultAdapter,108): Promise<SyncOutcome> {109 const result: SyncOutcome = {110 status: "errored",111 entriesReceived: 0,112 entriesSent: 0,113 tombstonesReceived: 0,114 tombstonesSent: 0,115 };116117 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);118 119 120 121 122 let stage: SyncStage = "preparing_hello";123 try {124 const ourVaultId = await vault.computeVaultId();125 const ourDeviceId = await vault.getOurDeviceId();126 const localVersion = await vault.getLocalVersion();127 const localTime = Date.now();128 const supportsDeviceManifest =129 vault.buildOutgoingDeviceManifest !== undefined &&130 vault.applyIncomingDeviceManifest !== undefined;131132 await sendMessage(session, {133 type: "SYNC_HELLO",134 vaultId: ourVaultId,135 deviceId: ourDeviceId,136 version: localVersion,137 deviceTime: localTime,138 deviceManifestVersion: supportsDeviceManifest139 ? DEVICE_MANIFEST_VERSION140 : undefined,141 });142 stage = "waiting_for_device_hello";143 const helloIn = await receiveMessage(session, timer);144 if (helloIn.type !== "SYNC_HELLO") {145 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);146 }147 if (helloIn.vaultId !== ourVaultId) {148 await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });149 result.status = "vault_mismatch";150 return result;151 }152153 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {154 console.warn(`[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);155 }156157 158 159 160 161 if (typeof helloIn.version === "number" && helloIn.version === localVersion) {162 if (163 supportsDeviceManifest &&164 helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION165 ) {166 stage = "building_device_manifest";167 const ours = await vault.buildOutgoingDeviceManifest!();168 await sendMessage(session, {169 type: "SYNC_DEVICE_MANIFEST",170 manifest: ours,171 });172 stage = "waiting_for_device_graph";173 const graphIn = await receiveMessage(session, timer);174 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {175 return errorOutcome(176 result,177 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,178 );179 }180 if (graphIn.manifest) {181 await vault.applyIncomingDeviceManifest!(182 graphIn.manifest,183 session.remoteStaticPublicKey,184 );185 }186 }187 await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });188 stage = "waiting_for_device_done";189 const closing = await awaitClosingDone(session, timer);190 if (closing.error) return errorOutcome(result, closing.error);191 result.status = "completed";192 return result;193 }194195 stage = "building_local_manifest";196 const ourEntries = await vault.buildEntryManifest();197 const ourTombstones = await vault.buildTombstoneManifest();198 await sendMessage(session, {199 type: "SYNC_MANIFEST",200 entries: ourEntries,201 tombstones: ourTombstones,202 });203 stage = "waiting_for_device_manifest";204 const manifestIn = await receiveMessage(session, timer);205 if (manifestIn.type !== "SYNC_MANIFEST") {206 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);207 }208209 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);210 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });211212 stage = "waiting_for_device_request";213 const requestIn = await receiveMessage(session, timer);214 if (requestIn.type !== "SYNC_REQUEST") {215 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);216 }217 218 219 220221 stage = "building_requested_entries";222 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);223 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });224 result.entriesSent = Object.keys(ourPayload).length;225226 if (wantFromPeer.length > 0) {227 stage = "waiting_for_device_entries";228 const entriesIn = await receiveMessage(session, timer);229 if (entriesIn.type !== "SYNC_ENTRIES") {230 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);231 }232 await vault.applyEntries(entriesIn.entries, manifestIn.entries);233 result.entriesReceived = Object.keys(entriesIn.entries).length;234 }235236 237 238 const newerTombstonesForPeer: Record<string, number> = {};239 for (const [id, ts] of Object.entries(ourTombstones)) {240 const peerTs = manifestIn.tombstones[id] ?? 0;241 if (ts > peerTs) newerTombstonesForPeer[id] = ts;242 }243 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });244 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;245246 stage = "waiting_for_device_tombstones";247 const tombstonesIn = await receiveMessage(session, timer);248 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {249 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);250 }251 await vault.applyTombstones(tombstonesIn.tombstones);252 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;253254 255 256 257 258 259 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {260 stage = "building_device_manifest";261 const ours = await vault.buildOutgoingDeviceManifest();262 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });263 stage = "waiting_for_device_graph";264 const manifestIn = await receiveMessage(session, timer);265 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {266 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);267 }268 if (manifestIn.manifest) {269 await vault.applyIncomingDeviceManifest(270 manifestIn.manifest,271 session.remoteStaticPublicKey,272 );273 }274 }275276 await sendMessage(session, {277 type: "SYNC_DONE",278 entriesReceived: result.entriesReceived,279 entriesSent: result.entriesSent,280 });281 stage = "waiting_for_device_done";282 const closing = await awaitClosingDone(session, timer);283 if (closing.error) return errorOutcome(result, closing.error);284 result.status = "completed";285 return result;286 } catch (err) {287 if ((err as Error).message === "sync_timeout") {288 result.status = "timeout";289 result.error = `sync_timeout:${stage}`;290 return result;291 }292 return errorOutcome(result, (err as Error).message);293 } finally {294 295 296 297 timer.cancel();298 }299}300301302303304305306export async function runSyncResponder(307 session: SyncSession,308 vault: SyncVaultAdapter,309): Promise<SyncOutcome> {310 const result: SyncOutcome = {311 status: "errored",312 entriesReceived: 0,313 entriesSent: 0,314 tombstonesReceived: 0,315 tombstonesSent: 0,316 };317 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);318 let stage: SyncStage = "waiting_for_device_hello";319 try {320 const ourVaultId = await vault.computeVaultId();321 const ourDeviceId = await vault.getOurDeviceId();322 const localVersion = await vault.getLocalVersion();323 const localTime = Date.now();324 const supportsDeviceManifest =325 vault.buildOutgoingDeviceManifest !== undefined &&326 vault.applyIncomingDeviceManifest !== undefined;327328 const helloIn = await receiveMessage(session, timer);329 if (helloIn.type !== "SYNC_HELLO") {330 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);331 }332 await sendMessage(session, {333 type: "SYNC_HELLO",334 vaultId: ourVaultId,335 deviceId: ourDeviceId,336 version: localVersion,337 deviceTime: localTime,338 deviceManifestVersion: supportsDeviceManifest339 ? DEVICE_MANIFEST_VERSION340 : undefined,341 });342 if (helloIn.vaultId !== ourVaultId) {343 result.status = "vault_mismatch";344 return result;345 }346 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {347 console.warn(348 `[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`,349 );350 }351352 if (helloIn.version === localVersion) {353 if (354 supportsDeviceManifest &&355 helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION356 ) {357 stage = "waiting_for_device_graph";358 const graphIn = await receiveMessage(session, timer);359 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {360 return errorOutcome(361 result,362 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,363 );364 }365 const ours = await vault.buildOutgoingDeviceManifest!();366 await sendMessage(session, {367 type: "SYNC_DEVICE_MANIFEST",368 manifest: ours,369 });370 if (graphIn.manifest) {371 await vault.applyIncomingDeviceManifest!(372 graphIn.manifest,373 session.remoteStaticPublicKey,374 );375 }376 }377 stage = "waiting_for_device_done";378 const done = await receiveMessage(session, timer);379 if (done.type !== "SYNC_DONE") {380 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);381 }382 await sendMessage(session, {383 type: "SYNC_DONE",384 entriesReceived: 0,385 entriesSent: 0,386 });387 result.status = "completed";388 return result;389 }390391 const ourEntries = await vault.buildEntryManifest();392 const ourTombstones = await vault.buildTombstoneManifest();393 stage = "waiting_for_device_manifest";394 const manifestIn = await receiveMessage(session, timer);395 if (manifestIn.type !== "SYNC_MANIFEST") {396 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);397 }398 await sendMessage(session, {399 type: "SYNC_MANIFEST",400 entries: ourEntries,401 tombstones: ourTombstones,402 });403404 stage = "waiting_for_device_request";405 const requestIn = await receiveMessage(session, timer);406 if (requestIn.type !== "SYNC_REQUEST") {407 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);408 }409 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);410 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });411412 stage = "waiting_for_device_entries";413 const entriesIn = await receiveMessage(session, timer);414 if (entriesIn.type !== "SYNC_ENTRIES") {415 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);416 }417 if (wantFromPeer.length > 0) {418 await vault.applyEntries(entriesIn.entries, manifestIn.entries);419 result.entriesReceived = Object.keys(entriesIn.entries).length;420 }421 if (requestIn.entryIds.length > 0) {422 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);423 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });424 result.entriesSent = Object.keys(ourPayload).length;425 }426427 stage = "waiting_for_device_tombstones";428 const tombstonesIn = await receiveMessage(session, timer);429 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {430 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);431 }432 await vault.applyTombstones(tombstonesIn.tombstones);433 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;434435 const newerTombstonesForPeer: Record<string, number> = {};436 for (const [id, timestamp] of Object.entries(ourTombstones)) {437 if (timestamp > (manifestIn.tombstones[id] ?? 0)) {438 newerTombstonesForPeer[id] = timestamp;439 }440 }441 await sendMessage(session, {442 type: "SYNC_TOMBSTONES",443 tombstones: newerTombstonesForPeer,444 });445 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;446447 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {448 stage = "waiting_for_device_graph";449 const graphIn = await receiveMessage(session, timer);450 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {451 return errorOutcome(452 result,453 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,454 );455 }456 const ours = await vault.buildOutgoingDeviceManifest();457 await sendMessage(session, {458 type: "SYNC_DEVICE_MANIFEST",459 manifest: ours,460 });461 if (graphIn.manifest) {462 await vault.applyIncomingDeviceManifest(463 graphIn.manifest,464 session.remoteStaticPublicKey,465 );466 }467 }468469 stage = "waiting_for_device_done";470 const done = await receiveMessage(session, timer);471 if (done.type !== "SYNC_DONE") {472 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);473 }474 await sendMessage(session, {475 type: "SYNC_DONE",476 entriesReceived: result.entriesReceived,477 entriesSent: result.entriesSent,478 });479 result.status = "completed";480 return result;481 } catch (error) {482 if ((error as Error).message === "sync_timeout") {483 result.status = "timeout";484 result.error = `sync_timeout:${stage}`;485 return result;486 }487 return errorOutcome(result, (error as Error).message);488 } finally {489 timer.cancel();490 }491}492493494495496497498499500501502503504505506async function awaitClosingDone(507 session: SyncSession,508 timer: Timer,509): Promise<{ error?: string }> {510 try {511 const done = await receiveMessage(session, timer);512 if (done.type !== "SYNC_DONE") {513 return { error: `expected SYNC_DONE, got ${done.type}` };514 }515 return {};516 } catch (err) {517 if ((err as Error).message === "sync_timeout") throw err;518 if (isConnectionClosedError(err)) return {};519 return { error: (err as Error).message };520 }521}522523function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {524 const want: string[] = [];525 for (const [id, ts] of Object.entries(remote)) {526 const ours = local[id];527 if (ours === undefined || ts > ours) want.push(id);528 }529 return want;530}531532interface Timer {533 signal: AbortSignal;534 cancel(): void;535}536537function scheduleTimeout(ms: number): Timer {538 const ctrl = new AbortController();539 const t = setTimeout(() => ctrl.abort(new Error("sync_timeout")), ms);540 return {541 signal: ctrl.signal,542 cancel: () => clearTimeout(t),543 };544}545546async function sendMessage(session: SyncSession, msg: SyncMessage): Promise<void> {547 session.send(new TextEncoder().encode(JSON.stringify(msg)));548}549550async function receiveMessage(session: SyncSession, timer: Timer): Promise<SyncMessage> {551 const pending = session.receive();552 const aborted = new Promise<never>((_, reject) => {553 if (timer.signal.aborted) reject(new Error("sync_timeout"));554 timer.signal.addEventListener("abort", () => reject(new Error("sync_timeout")));555 });556 const bytes = await Promise.race([pending, aborted]);557 558 559 560 if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {561 throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);562 }563 const parsed = JSON.parse(new TextDecoder().decode(bytes));564 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");565 return parsed as SyncMessage;566}567568function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {569 outcome.status = "errored";570 outcome.error = error;571 return outcome;572}