sync-limits.ts

4.6 KB · raw

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 * Display/storage bound for device names. The LOCAL name already obeys this29 * bound at the Settings write path (device-identity.ts setDeviceName);30 * peer-supplied names (manifest devices, vouch requests, alert fallbacks)31 * historically had none, letting a hostile peer grow chrome.storage without32 * limit and blow out fixed-width UI. Inbound seams cap rather than reject so33 * old peers with long-but-honest names keep syncing.34 */35export const MAX_DEVICE_NAME_CHARS = 64;3637export function capDeviceName(name: string): string {38  return name.length <= MAX_DEVICE_NAME_CHARS39    ? name40    : name.slice(0, MAX_DEVICE_NAME_CHARS);41}4243/**44 * HOLE 2 coverage audit — every peer-supplied timestamp in the sync protocol,45 * and what stops it influencing ordering. Re-check this list when adding a46 * timestamp to any wire type.47 *48 *   entry.modifiedAt      clamped (vault.ts applyMergedRemoteEntries)49 *   entry.deletedAt       clamped (vault.ts applyMergedRemoteEntries)50 *   tombstone ts          clamped twice — sync-vault-adapter.ts boundary and51 *                         again in vault.ts, deliberately52 *   manifest.issuedAt     NOT clamped, and must not be: it is checked as a53 *                         two-sided freshness window (|now - issuedAt| >54 *                         DEVICE_MANIFEST_FRESHNESS_MS rejects the manifest55 *                         outright). Rejecting is strictly stronger than56 *                         clamping, and it never participates in ordering.57 *   entry.pairedAt        never compared — stored and displayed only. A peer58 *                         cannot reorder anything with it, and the manifest59 *                         upsert keeps `existing.pairedAt` when we already60 *                         know the device.61 *   revocation.revokedAt  clamped on merge for display sanity. Revocation is62 *                         terminal, so it is not an ordering input by63 *                         construction — that is the actual protection.64 *   SYNC_HELLO.deviceTime warning only; never merged or compared for ordering.65 */6667/**68 * MV-3: hard resource bounds on the sync receive path. A paired/vouched69 * peer (or anything that passes the Noise KK mutual static-key pin) must not be70 * able to OOM the service worker with one frame or one giant manifest.71 */72export const SYNC_LIMITS = {73  /**74   * Max bytes per WebSocket/Noise frame. Cloudflare Workers closes messages75   * above 32 MiB; 24 MiB leaves transport overhead and platform margin.76   */77  MAX_FRAME_BYTES: 24 * 1024 * 1024, // 24 MiB78  MAX_INBOX_BYTES: 32 * 1024 * 1024, // one max frame plus bounded control traffic79  MAX_INBOX_MESSAGES: 256,80  /** Max entries accepted in one SYNC_ENTRIES / manifest / request payload. */81  MAX_ENTRY_COUNT: 100_000,82  /** Max IDs a peer may request before per-entry crypto work begins. */83  MAX_REQUEST_ENTRY_COUNT: 100_000,84  /** Max plaintext size of one decrypted entry envelope. */85  MAX_ENTRY_PLAINTEXT_BYTES: 512 * 1024, // 512 KiB86  /** Max tombstones accepted in one SYNC_TOMBSTONES payload. */87  MAX_TOMBSTONE_COUNT: 100_000,88} as const;8990export function validateSyncManifestCounts(91  entries: Record<string, number>,92  tombstones: Record<string, number>,93): void {94  if (Object.keys(entries).length > SYNC_LIMITS.MAX_ENTRY_COUNT) {95    throw new Error("sync manifest entry count exceeds limit");96  }97  if (Object.keys(tombstones).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {98    throw new Error("sync manifest tombstone count exceeds limit");99  }100}