// Sync trust + resource bounds — audit fixes HV-1 (timestamp clamp) and // MV-3 (receive-path size bounds). Shared between the background sync // stack (vault.ts, sync-vault-adapter.ts, sync-coordinator.ts) // and the transport (shared/sync-transport.ts), so it lives in shared/. /** * HV-1: maximum tolerated forward clock skew on any timestamp received from * a sync peer. Matches the SYNC_HELLO skew warning window's intent — a peer * whose clock is slightly ahead still merges cleanly, but a deliberately * far-future `modifiedAt`/`deletedAt` can no longer permanently pin a * malicious value against honest local edits. */ export const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; /** * Clamp a peer-supplied timestamp to `now + MAX_CLOCK_SKEW_MS`. Applied to * every remote `modifiedAt` and `deletedAt` BEFORE it is compared against * any local timestamp (HV-1). A clamped far-future timestamp loses to any * local edit made more than MAX_CLOCK_SKEW_MS from now, and the monotonic * local entry clock (vault.ts monotonicEntryTimestamp) can always advance * past it. */ export function clampSyncTimestamp(ts: number, now: number = Date.now()): number { return Math.min(ts, now + MAX_CLOCK_SKEW_MS); } /** * Display/storage bound for device names. The LOCAL name already obeys this * bound at the Settings write path (device-identity.ts setDeviceName); * peer-supplied names (manifest devices, vouch requests, alert fallbacks) * historically had none, letting a hostile peer grow chrome.storage without * limit and blow out fixed-width UI. Inbound seams cap rather than reject so * old peers with long-but-honest names keep syncing. */ export const MAX_DEVICE_NAME_CHARS = 64; export function capDeviceName(name: string): string { return name.length <= MAX_DEVICE_NAME_CHARS ? name : name.slice(0, MAX_DEVICE_NAME_CHARS); } /** * HOLE 2 coverage audit — every peer-supplied timestamp in the sync protocol, * and what stops it influencing ordering. Re-check this list when adding a * timestamp to any wire type. * * entry.modifiedAt clamped (vault.ts applyMergedRemoteEntries) * entry.deletedAt clamped (vault.ts applyMergedRemoteEntries) * tombstone ts clamped twice — sync-vault-adapter.ts boundary and * again in vault.ts, deliberately * manifest.issuedAt NOT clamped, and must not be: it is checked as a * two-sided freshness window (|now - issuedAt| > * DEVICE_MANIFEST_FRESHNESS_MS rejects the manifest * outright). Rejecting is strictly stronger than * clamping, and it never participates in ordering. * entry.pairedAt never compared — stored and displayed only. A peer * cannot reorder anything with it, and the manifest * upsert keeps `existing.pairedAt` when we already * know the device. * revocation.revokedAt clamped on merge for display sanity. Revocation is * terminal, so it is not an ordering input by * construction — that is the actual protection. * SYNC_HELLO.deviceTime warning only; never merged or compared for ordering. */ /** * MV-3: hard resource bounds on the sync receive path. A paired/vouched * peer (or anything that passes the Noise KK mutual static-key pin) must not be * able to OOM the service worker with one frame or one giant manifest. */ export const SYNC_LIMITS = { /** * Max bytes per WebSocket/Noise frame. Cloudflare Workers closes messages * above 32 MiB; 24 MiB leaves transport overhead and platform margin. */ MAX_FRAME_BYTES: 24 * 1024 * 1024, // 24 MiB MAX_INBOX_BYTES: 32 * 1024 * 1024, // one max frame plus bounded control traffic MAX_INBOX_MESSAGES: 256, /** Max entries accepted in one SYNC_ENTRIES / manifest / request payload. */ MAX_ENTRY_COUNT: 100_000, /** Max IDs a peer may request before per-entry crypto work begins. */ MAX_REQUEST_ENTRY_COUNT: 100_000, /** Max plaintext size of one decrypted entry envelope. */ MAX_ENTRY_PLAINTEXT_BYTES: 512 * 1024, // 512 KiB /** Max tombstones accepted in one SYNC_TOMBSTONES payload. */ MAX_TOMBSTONE_COUNT: 100_000, } as const; export function validateSyncManifestCounts( entries: Record, tombstones: Record, ): void { if (Object.keys(entries).length > SYNC_LIMITS.MAX_ENTRY_COUNT) { throw new Error("sync manifest entry count exceeds limit"); } if (Object.keys(tombstones).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) { throw new Error("sync manifest tombstone count exceeds limit"); } }