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 164 165 166 167 if (typeof helloIn.version === "number" && helloIn.version === localVersion) {168 if (169 supportsDeviceManifest &&170 helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION171 ) {172 stage = "building_device_manifest";173 const ours = await vault.buildOutgoingDeviceManifest!();174 await sendMessage(session, {175 type: "SYNC_DEVICE_MANIFEST",176 manifest: ours,177 });178 stage = "waiting_for_device_graph";179 const graphIn = await receiveMessage(session, timer);180 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {181 return errorOutcome(182 result,183 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,184 );185 }186 if (graphIn.manifest) {187 await vault.applyIncomingDeviceManifest!(188 graphIn.manifest,189 session.remoteStaticPublicKey,190 );191 }192 }193 await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });194 stage = "waiting_for_device_done";195 const closing = await awaitClosingDone(session, timer);196 if (closing.error) return errorOutcome(result, closing.error);197 result.status = "completed";198 return result;199 }200201 stage = "building_local_manifest";202 const ourEntries = await vault.buildEntryManifest();203 const ourTombstones = await vault.buildTombstoneManifest();204 await sendMessage(session, {205 type: "SYNC_MANIFEST",206 entries: ourEntries,207 tombstones: ourTombstones,208 });209 stage = "waiting_for_device_manifest";210 const manifestIn = await receiveMessage(session, timer);211 if (manifestIn.type !== "SYNC_MANIFEST") {212 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);213 }214 validateSyncManifestCounts(manifestIn.entries, manifestIn.tombstones);215216 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);217 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });218219 stage = "waiting_for_device_request";220 const requestIn = await receiveMessage(session, timer);221 if (requestIn.type !== "SYNC_REQUEST") {222 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);223 }224 if (requestIn.entryIds.length > SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT) {225 return errorOutcome(result, "sync request entry count exceeds limit");226 }227 228 229 230231 stage = "building_requested_entries";232 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);233 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });234 result.entriesSent = Object.keys(ourPayload).length;235236 if (wantFromPeer.length > 0) {237 stage = "waiting_for_device_entries";238 const entriesIn = await receiveMessage(session, timer);239 if (entriesIn.type !== "SYNC_ENTRIES") {240 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);241 }242 await vault.applyEntries(entriesIn.entries, manifestIn.entries);243 result.entriesReceived = Object.keys(entriesIn.entries).length;244 }245246 247 248 const newerTombstonesForPeer: Record<string, number> = {};249 for (const [id, ts] of Object.entries(ourTombstones)) {250 const peerTs = manifestIn.tombstones[id] ?? 0;251 if (ts > peerTs) newerTombstonesForPeer[id] = ts;252 }253 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });254 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;255256 stage = "waiting_for_device_tombstones";257 const tombstonesIn = await receiveMessage(session, timer);258 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {259 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);260 }261 await vault.applyTombstones(tombstonesIn.tombstones);262 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;263264 265 266 267 268 269 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {270 stage = "building_device_manifest";271 const ours = await vault.buildOutgoingDeviceManifest();272 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });273 stage = "waiting_for_device_graph";274 const manifestIn = await receiveMessage(session, timer);275 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {276 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);277 }278 if (manifestIn.manifest) {279 await vault.applyIncomingDeviceManifest(280 manifestIn.manifest,281 session.remoteStaticPublicKey,282 );283 }284 }285286 await sendMessage(session, {287 type: "SYNC_DONE",288 entriesReceived: result.entriesReceived,289 entriesSent: result.entriesSent,290 });291 stage = "waiting_for_device_done";292 const closing = await awaitClosingDone(session, timer);293 if (closing.error) return errorOutcome(result, closing.error);294 result.status = "completed";295 return result;296 } catch (err) {297 if ((err as Error).message === "sync_timeout") {298 result.status = "timeout";299 result.error = `sync_timeout:${stage}`;300 return result;301 }302 return errorOutcome(result, (err as Error).message);303 } finally {304 305 306 307 timer.cancel();308 }309}310311312313314315316export async function runSyncResponder(317 session: SyncSession,318 vault: SyncVaultAdapter,319): Promise<SyncOutcome> {320 const result: SyncOutcome = {321 status: "errored",322 entriesReceived: 0,323 entriesSent: 0,324 tombstonesReceived: 0,325 tombstonesSent: 0,326 };327 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);328 let stage: SyncStage = "waiting_for_device_hello";329 try {330 const ourVaultId = await vault.computeVaultId();331 const ourDeviceId = await vault.getOurDeviceId();332 const localVersion = await vault.getLocalVersion();333 const localTime = Date.now();334 const supportsDeviceManifest =335 vault.buildOutgoingDeviceManifest !== undefined &&336 vault.applyIncomingDeviceManifest !== undefined;337338 const helloIn = await receiveMessage(session, timer);339 if (helloIn.type !== "SYNC_HELLO") {340 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);341 }342 if ((helloIn.protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION) {343 await sendMessage(session, { type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });344 return errorOutcome(result, "PEER_PROTOCOL_OUTDATED");345 }346 await sendMessage(session, {347 type: "SYNC_HELLO",348 vaultId: ourVaultId,349 deviceId: ourDeviceId,350 version: localVersion,351 deviceTime: localTime,352 protocolVersion: SYNC_PROTOCOL_VERSION,353 deviceManifestVersion: supportsDeviceManifest354 ? DEVICE_MANIFEST_VERSION355 : undefined,356 });357 if (helloIn.vaultId !== ourVaultId) {358 result.status = "vault_mismatch";359 return result;360 }361 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {362 console.warn(363 `[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`,364 );365 }366367 if (helloIn.version === localVersion) {368 if (369 supportsDeviceManifest &&370 helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION371 ) {372 stage = "waiting_for_device_graph";373 const graphIn = await receiveMessage(session, timer);374 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {375 return errorOutcome(376 result,377 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,378 );379 }380 const ours = await vault.buildOutgoingDeviceManifest!();381 await sendMessage(session, {382 type: "SYNC_DEVICE_MANIFEST",383 manifest: ours,384 });385 if (graphIn.manifest) {386 await vault.applyIncomingDeviceManifest!(387 graphIn.manifest,388 session.remoteStaticPublicKey,389 );390 }391 }392 stage = "waiting_for_device_done";393 const done = await receiveMessage(session, timer);394 if (done.type !== "SYNC_DONE") {395 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);396 }397 await sendMessage(session, {398 type: "SYNC_DONE",399 entriesReceived: 0,400 entriesSent: 0,401 });402 result.status = "completed";403 return result;404 }405406 const ourEntries = await vault.buildEntryManifest();407 const ourTombstones = await vault.buildTombstoneManifest();408 stage = "waiting_for_device_manifest";409 const manifestIn = await receiveMessage(session, timer);410 if (manifestIn.type !== "SYNC_MANIFEST") {411 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);412 }413 validateSyncManifestCounts(manifestIn.entries, manifestIn.tombstones);414 await sendMessage(session, {415 type: "SYNC_MANIFEST",416 entries: ourEntries,417 tombstones: ourTombstones,418 });419420 stage = "waiting_for_device_request";421 const requestIn = await receiveMessage(session, timer);422 if (requestIn.type !== "SYNC_REQUEST") {423 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);424 }425 if (requestIn.entryIds.length > SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT) {426 return errorOutcome(result, "sync request entry count exceeds limit");427 }428 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);429 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });430431 stage = "waiting_for_device_entries";432 const entriesIn = await receiveMessage(session, timer);433 if (entriesIn.type !== "SYNC_ENTRIES") {434 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);435 }436 if (wantFromPeer.length > 0) {437 await vault.applyEntries(entriesIn.entries, manifestIn.entries);438 result.entriesReceived = Object.keys(entriesIn.entries).length;439 }440 if (requestIn.entryIds.length > 0) {441 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);442 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });443 result.entriesSent = Object.keys(ourPayload).length;444 }445446 stage = "waiting_for_device_tombstones";447 const tombstonesIn = await receiveMessage(session, timer);448 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {449 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);450 }451 await vault.applyTombstones(tombstonesIn.tombstones);452 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;453454 const newerTombstonesForPeer: Record<string, number> = {};455 for (const [id, timestamp] of Object.entries(ourTombstones)) {456 if (timestamp > (manifestIn.tombstones[id] ?? 0)) {457 newerTombstonesForPeer[id] = timestamp;458 }459 }460 await sendMessage(session, {461 type: "SYNC_TOMBSTONES",462 tombstones: newerTombstonesForPeer,463 });464 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;465466 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {467 stage = "waiting_for_device_graph";468 const graphIn = await receiveMessage(session, timer);469 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {470 return errorOutcome(471 result,472 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,473 );474 }475 const ours = await vault.buildOutgoingDeviceManifest();476 await sendMessage(session, {477 type: "SYNC_DEVICE_MANIFEST",478 manifest: ours,479 });480 if (graphIn.manifest) {481 await vault.applyIncomingDeviceManifest(482 graphIn.manifest,483 session.remoteStaticPublicKey,484 );485 }486 }487488 stage = "waiting_for_device_done";489 const done = await receiveMessage(session, timer);490 if (done.type !== "SYNC_DONE") {491 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);492 }493 await sendMessage(session, {494 type: "SYNC_DONE",495 entriesReceived: result.entriesReceived,496 entriesSent: result.entriesSent,497 });498 result.status = "completed";499 return result;500 } catch (error) {501 if ((error as Error).message === "sync_timeout") {502 result.status = "timeout";503 result.error = `sync_timeout:${stage}`;504 return result;505 }506 return errorOutcome(result, (error as Error).message);507 } finally {508 timer.cancel();509 }510}511512513514515516517518519520521522523524525async function awaitClosingDone(526 session: SyncSession,527 timer: Timer,528): Promise<{ error?: string }> {529 try {530 const done = await receiveMessage(session, timer);531 if (done.type !== "SYNC_DONE") {532 return { error: `expected SYNC_DONE, got ${done.type}` };533 }534 return {};535 } catch (err) {536 if ((err as Error).message === "sync_timeout") throw err;537 if (isConnectionClosedError(err)) return {};538 return { error: (err as Error).message };539 }540}541542function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {543 const want: string[] = [];544 for (const [id, ts] of Object.entries(remote)) {545 const ours = local[id];546 if (ours === undefined || ts > ours) want.push(id);547 }548 return want;549}550551interface Timer {552 signal: AbortSignal;553 cancel(): void;554}555556function scheduleTimeout(ms: number): Timer {557 const ctrl = new AbortController();558 const t = setTimeout(() => ctrl.abort(new Error("sync_timeout")), ms);559 return {560 signal: ctrl.signal,561 cancel: () => clearTimeout(t),562 };563}564565async function sendMessage(session: SyncSession, msg: SyncMessage): Promise<void> {566 session.send(new TextEncoder().encode(JSON.stringify(msg)));567}568569async function receiveMessage(session: SyncSession, timer: Timer): Promise<SyncMessage> {570 const pending = session.receive();571 const aborted = new Promise<never>((_, reject) => {572 if (timer.signal.aborted) reject(new Error("sync_timeout"));573 timer.signal.addEventListener("abort", () => reject(new Error("sync_timeout")));574 });575 const bytes = await Promise.race([pending, aborted]);576 577 578 579 if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {580 throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);581 }582 const parsed = JSON.parse(new TextDecoder().decode(bytes));583 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");584 return parsed as SyncMessage;585}586587function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {588 outcome.status = "errored";589 outcome.error = error;590 return outcome;591}