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, validateSyncManifestCounts } 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 }208 validateSyncManifestCounts(manifestIn.entries, manifestIn.tombstones);209210 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);211 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });212213 stage = "waiting_for_device_request";214 const requestIn = await receiveMessage(session, timer);215 if (requestIn.type !== "SYNC_REQUEST") {216 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);217 }218 if (requestIn.entryIds.length > SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT) {219 return errorOutcome(result, "sync request entry count exceeds limit");220 }221 222 223 224225 stage = "building_requested_entries";226 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);227 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });228 result.entriesSent = Object.keys(ourPayload).length;229230 if (wantFromPeer.length > 0) {231 stage = "waiting_for_device_entries";232 const entriesIn = await receiveMessage(session, timer);233 if (entriesIn.type !== "SYNC_ENTRIES") {234 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);235 }236 await vault.applyEntries(entriesIn.entries, manifestIn.entries);237 result.entriesReceived = Object.keys(entriesIn.entries).length;238 }239240 241 242 const newerTombstonesForPeer: Record<string, number> = {};243 for (const [id, ts] of Object.entries(ourTombstones)) {244 const peerTs = manifestIn.tombstones[id] ?? 0;245 if (ts > peerTs) newerTombstonesForPeer[id] = ts;246 }247 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });248 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;249250 stage = "waiting_for_device_tombstones";251 const tombstonesIn = await receiveMessage(session, timer);252 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {253 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);254 }255 await vault.applyTombstones(tombstonesIn.tombstones);256 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;257258 259 260 261 262 263 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {264 stage = "building_device_manifest";265 const ours = await vault.buildOutgoingDeviceManifest();266 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });267 stage = "waiting_for_device_graph";268 const manifestIn = await receiveMessage(session, timer);269 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {270 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);271 }272 if (manifestIn.manifest) {273 await vault.applyIncomingDeviceManifest(274 manifestIn.manifest,275 session.remoteStaticPublicKey,276 );277 }278 }279280 await sendMessage(session, {281 type: "SYNC_DONE",282 entriesReceived: result.entriesReceived,283 entriesSent: result.entriesSent,284 });285 stage = "waiting_for_device_done";286 const closing = await awaitClosingDone(session, timer);287 if (closing.error) return errorOutcome(result, closing.error);288 result.status = "completed";289 return result;290 } catch (err) {291 if ((err as Error).message === "sync_timeout") {292 result.status = "timeout";293 result.error = `sync_timeout:${stage}`;294 return result;295 }296 return errorOutcome(result, (err as Error).message);297 } finally {298 299 300 301 timer.cancel();302 }303}304305306307308309310export async function runSyncResponder(311 session: SyncSession,312 vault: SyncVaultAdapter,313): Promise<SyncOutcome> {314 const result: SyncOutcome = {315 status: "errored",316 entriesReceived: 0,317 entriesSent: 0,318 tombstonesReceived: 0,319 tombstonesSent: 0,320 };321 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);322 let stage: SyncStage = "waiting_for_device_hello";323 try {324 const ourVaultId = await vault.computeVaultId();325 const ourDeviceId = await vault.getOurDeviceId();326 const localVersion = await vault.getLocalVersion();327 const localTime = Date.now();328 const supportsDeviceManifest =329 vault.buildOutgoingDeviceManifest !== undefined &&330 vault.applyIncomingDeviceManifest !== undefined;331332 const helloIn = await receiveMessage(session, timer);333 if (helloIn.type !== "SYNC_HELLO") {334 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);335 }336 await sendMessage(session, {337 type: "SYNC_HELLO",338 vaultId: ourVaultId,339 deviceId: ourDeviceId,340 version: localVersion,341 deviceTime: localTime,342 deviceManifestVersion: supportsDeviceManifest343 ? DEVICE_MANIFEST_VERSION344 : undefined,345 });346 if (helloIn.vaultId !== ourVaultId) {347 result.status = "vault_mismatch";348 return result;349 }350 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {351 console.warn(352 `[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`,353 );354 }355356 if (helloIn.version === localVersion) {357 if (358 supportsDeviceManifest &&359 helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION360 ) {361 stage = "waiting_for_device_graph";362 const graphIn = await receiveMessage(session, timer);363 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {364 return errorOutcome(365 result,366 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,367 );368 }369 const ours = await vault.buildOutgoingDeviceManifest!();370 await sendMessage(session, {371 type: "SYNC_DEVICE_MANIFEST",372 manifest: ours,373 });374 if (graphIn.manifest) {375 await vault.applyIncomingDeviceManifest!(376 graphIn.manifest,377 session.remoteStaticPublicKey,378 );379 }380 }381 stage = "waiting_for_device_done";382 const done = await receiveMessage(session, timer);383 if (done.type !== "SYNC_DONE") {384 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);385 }386 await sendMessage(session, {387 type: "SYNC_DONE",388 entriesReceived: 0,389 entriesSent: 0,390 });391 result.status = "completed";392 return result;393 }394395 const ourEntries = await vault.buildEntryManifest();396 const ourTombstones = await vault.buildTombstoneManifest();397 stage = "waiting_for_device_manifest";398 const manifestIn = await receiveMessage(session, timer);399 if (manifestIn.type !== "SYNC_MANIFEST") {400 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);401 }402 validateSyncManifestCounts(manifestIn.entries, manifestIn.tombstones);403 await sendMessage(session, {404 type: "SYNC_MANIFEST",405 entries: ourEntries,406 tombstones: ourTombstones,407 });408409 stage = "waiting_for_device_request";410 const requestIn = await receiveMessage(session, timer);411 if (requestIn.type !== "SYNC_REQUEST") {412 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);413 }414 if (requestIn.entryIds.length > SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT) {415 return errorOutcome(result, "sync request entry count exceeds limit");416 }417 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);418 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });419420 stage = "waiting_for_device_entries";421 const entriesIn = await receiveMessage(session, timer);422 if (entriesIn.type !== "SYNC_ENTRIES") {423 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);424 }425 if (wantFromPeer.length > 0) {426 await vault.applyEntries(entriesIn.entries, manifestIn.entries);427 result.entriesReceived = Object.keys(entriesIn.entries).length;428 }429 if (requestIn.entryIds.length > 0) {430 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);431 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });432 result.entriesSent = Object.keys(ourPayload).length;433 }434435 stage = "waiting_for_device_tombstones";436 const tombstonesIn = await receiveMessage(session, timer);437 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {438 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);439 }440 await vault.applyTombstones(tombstonesIn.tombstones);441 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;442443 const newerTombstonesForPeer: Record<string, number> = {};444 for (const [id, timestamp] of Object.entries(ourTombstones)) {445 if (timestamp > (manifestIn.tombstones[id] ?? 0)) {446 newerTombstonesForPeer[id] = timestamp;447 }448 }449 await sendMessage(session, {450 type: "SYNC_TOMBSTONES",451 tombstones: newerTombstonesForPeer,452 });453 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;454455 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {456 stage = "waiting_for_device_graph";457 const graphIn = await receiveMessage(session, timer);458 if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {459 return errorOutcome(460 result,461 `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,462 );463 }464 const ours = await vault.buildOutgoingDeviceManifest();465 await sendMessage(session, {466 type: "SYNC_DEVICE_MANIFEST",467 manifest: ours,468 });469 if (graphIn.manifest) {470 await vault.applyIncomingDeviceManifest(471 graphIn.manifest,472 session.remoteStaticPublicKey,473 );474 }475 }476477 stage = "waiting_for_device_done";478 const done = await receiveMessage(session, timer);479 if (done.type !== "SYNC_DONE") {480 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);481 }482 await sendMessage(session, {483 type: "SYNC_DONE",484 entriesReceived: result.entriesReceived,485 entriesSent: result.entriesSent,486 });487 result.status = "completed";488 return result;489 } catch (error) {490 if ((error as Error).message === "sync_timeout") {491 result.status = "timeout";492 result.error = `sync_timeout:${stage}`;493 return result;494 }495 return errorOutcome(result, (error as Error).message);496 } finally {497 timer.cancel();498 }499}500501502503504505506507508509510511512513514async function awaitClosingDone(515 session: SyncSession,516 timer: Timer,517): Promise<{ error?: string }> {518 try {519 const done = await receiveMessage(session, timer);520 if (done.type !== "SYNC_DONE") {521 return { error: `expected SYNC_DONE, got ${done.type}` };522 }523 return {};524 } catch (err) {525 if ((err as Error).message === "sync_timeout") throw err;526 if (isConnectionClosedError(err)) return {};527 return { error: (err as Error).message };528 }529}530531function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {532 const want: string[] = [];533 for (const [id, ts] of Object.entries(remote)) {534 const ours = local[id];535 if (ours === undefined || ts > ours) want.push(id);536 }537 return want;538}539540interface Timer {541 signal: AbortSignal;542 cancel(): void;543}544545function scheduleTimeout(ms: number): Timer {546 const ctrl = new AbortController();547 const t = setTimeout(() => ctrl.abort(new Error("sync_timeout")), ms);548 return {549 signal: ctrl.signal,550 cancel: () => clearTimeout(t),551 };552}553554async function sendMessage(session: SyncSession, msg: SyncMessage): Promise<void> {555 session.send(new TextEncoder().encode(JSON.stringify(msg)));556}557558async function receiveMessage(session: SyncSession, timer: Timer): Promise<SyncMessage> {559 const pending = session.receive();560 const aborted = new Promise<never>((_, reject) => {561 if (timer.signal.aborted) reject(new Error("sync_timeout"));562 timer.signal.addEventListener("abort", () => reject(new Error("sync_timeout")));563 });564 const bytes = await Promise.race([pending, aborted]);565 566 567 568 if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {569 throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);570 }571 const parsed = JSON.parse(new TextDecoder().decode(bytes));572 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");573 return parsed as SyncMessage;574}575576function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {577 outcome.status = "errored";578 outcome.error = error;579 return outcome;580}