package com.peachpasswords.sync import java.io.IOException /** * Cloudflare Workers caps one WebSocket message at 32 MiB. Keep a generous * margin for the Noise authentication tag and platform framing overhead. */ const val MAX_SYNC_FRAME_BYTES: Int = 24 * 1024 * 1024 class SyncFrameTooLargeException( message: String = "sync frame too large (WebSocket close 1009)", ) : IOException(message) /** * A decoded frame whose declared structure exceeds the protocol's limits. An * IOException so it lands in the same ERRORED outcome as a transport failure. */ class SyncStructureTooLargeException(what: String) : IOException("sync structure exceeds limits: $what") fun requireSyncFrameSize(size: Int) { if (size > MAX_SYNC_FRAME_BYTES) { throw SyncFrameTooLargeException("sync frame exceeds $MAX_SYNC_FRAME_BYTES bytes") } } /** * Timestamp bounds, ported from peach-browser `src/shared/sync-limits.ts`. * Both sides must agree, so change them together. */ object SyncLimits { const val MAX_ENTRY_COUNT = 100_000 const val MAX_REQUEST_ENTRY_COUNT = 100_000 const val MAX_TOMBSTONE_COUNT = 100_000 /** * Entry and device identifiers are UUID strings (36 chars). The cap exists * so a peer cannot pad a map with multi-kilobyte keys — the count limits * alone leave the cost of any single key unbounded. * * Deliberately loose: every import path on both platforms preserves the id * a restored backup already carries (the browser's `sanitizeRestoredEntry`, * Android's `draft.id.ifBlank { UUID }`), and neither bounds it, so a cap * set tight to a UUID would turn one such restored entry into a permanent * sync failure. The real protection is the count cap plus the frame byte * cap, which together bound total bytes however long a single key is. */ const val MAX_ID_LENGTH = 512 const val MAX_ENTRY_PLAINTEXT_BYTES = 512 * 1024 const val MAX_ENTRY_ENVELOPE_BYTES = (MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64 /** * HV-1: maximum tolerated forward clock skew on any timestamp received from * a sync peer. 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. */ const val MAX_CLOCK_SKEW_MS = 5 * 60 * 1000L /** * 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. * * See the HOLE 2 coverage audit in the browser's `sync-limits.ts` for the * full list of peer-supplied timestamps and what bounds each one. */ fun clampSyncTimestamp(ts: Long, now: Long = System.currentTimeMillis()): Long = minOf(ts, now + MAX_CLOCK_SKEW_MS) }