sync-limits.ts
1// Sync trust + resource bounds — audit fixes HV-1 (timestamp clamp) and2// MV-3 (receive-path size bounds). Shared between the background sync3// stack (vault.ts, sync-vault-adapter.ts, sync-coordinator.ts)4// and the transport (shared/sync-transport.ts), so it lives in shared/.56/**7 * HV-1: maximum tolerated forward clock skew on any timestamp received from8 * a sync peer. Matches the SYNC_HELLO skew warning window's intent — a peer9 * whose clock is slightly ahead still merges cleanly, but a deliberately10 * far-future `modifiedAt`/`deletedAt` can no longer permanently pin a11 * malicious value against honest local edits.12 */13export const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;1415/**16 * Clamp a peer-supplied timestamp to `now + MAX_CLOCK_SKEW_MS`. Applied to17 * every remote `modifiedAt` and `deletedAt` BEFORE it is compared against18 * any local timestamp (HV-1). A clamped far-future timestamp loses to any19 * local edit made more than MAX_CLOCK_SKEW_MS from now, and the monotonic20 * local entry clock (vault.ts monotonicEntryTimestamp) can always advance21 * past it.22 */23export function clampSyncTimestamp(ts: number, now: number = Date.now()): number {24 return Math.min(ts, now + MAX_CLOCK_SKEW_MS);25}2627/**28 * HOLE 2 coverage audit — every peer-supplied timestamp in the sync protocol,29 * and what stops it influencing ordering. Re-check this list when adding a30 * timestamp to any wire type.31 *32 * entry.modifiedAt clamped (vault.ts applyMergedRemoteEntries)33 * entry.deletedAt clamped (vault.ts applyMergedRemoteEntries)34 * tombstone ts clamped twice — sync-vault-adapter.ts boundary and35 * again in vault.ts, deliberately36 * manifest.issuedAt NOT clamped, and must not be: it is checked as a37 * two-sided freshness window (|now - issuedAt| >38 * DEVICE_MANIFEST_FRESHNESS_MS rejects the manifest39 * outright). Rejecting is strictly stronger than40 * clamping, and it never participates in ordering.41 * entry.pairedAt never compared — stored and displayed only. A peer42 * cannot reorder anything with it, and the manifest43 * upsert keeps `existing.pairedAt` when we already44 * know the device.45 * revocation.revokedAt clamped on merge for display sanity. Revocation is46 * terminal, so it is not an ordering input by47 * construction — that is the actual protection.48 * SYNC_HELLO.deviceTime warning only; never merged or compared for ordering.49 */5051/**52 * MV-3: hard resource bounds on the sync receive path. A paired/vouched53 * peer (or anything that passes the Noise KK mutual static-key pin) must not be54 * able to OOM the service worker with one frame or one giant manifest.55 */56export const SYNC_LIMITS = {57 /**58 * Max bytes per WebSocket/Noise frame. Cloudflare Workers closes messages59 * above 32 MiB; 24 MiB leaves transport overhead and platform margin.60 */61 MAX_FRAME_BYTES: 24 * 1024 * 1024, // 24 MiB62 /** Max entries accepted in one SYNC_ENTRIES / manifest payload. */63 MAX_ENTRY_COUNT: 100_000,64 /** Max plaintext size of one decrypted entry envelope. */65 MAX_ENTRY_PLAINTEXT_BYTES: 512 * 1024, // 512 KiB66 /** Max tombstones accepted in one SYNC_TOMBSTONES payload. */67 MAX_TOMBSTONE_COUNT: 100_000,68} as const;