/** * sframe-replay.ts — durable, cross-reload anti-replay for the SFrame chat provider. * * ## Why (SEC-CR-003, CWE-294 replay) * sframe-ratchet's receiver-side replay window is an in-memory bounded `Set` that is WIPED * on page reload. `ctrStrategy: 'monotonic-idb'` only persists the SENDER's CTR allocator — * NOT the receiver's replay defense (the `unseal` path checks a fresh in-memory window * regardless of strategy). So after a reload a malicious / compromised app-server can * re-serve an OLD authentic sealed frame under a fresh msg_id and it verifies (the ciphertext * is genuinely authentic, just old), rendering a stale message as new. * * ## What * This guard persists the set of already-accepted per-(room, sender) CTRs to IndexedDB (via * `idb-keyval`, the same store the outbox uses) so the replay defense survives a reload. The * CTR is read from the RFC 9605 §4.3 header via the library's own `parseHeader` — the header * is the AEAD AAD, so the CTR is authenticated (an attacker cannot alter it without failing * AEAD). The guard mirrors the library's in-memory bounded-set semantics exactly, just durable. * * ## Availability * Feature-detected + default-on. Durable persistence requires BOTH IndexedDB AND the Web Locks * API: when either is unavailable (SSR / Node without a polyfill / private-mode quirks / legacy * Safari <15.4 with no Web Locks) the guard degrades to a no-op with a one-time warning, and the * library's in-memory window remains the only (session-scoped) defense — the guard never throws * at construct and never breaks such a runtime. A `window` of 0 disables the durable window * (mirrors sframe-ratchet's `replayWindow: 0` debug switch). * * ## Concurrency * Same-realm writes are serialized by a promise chain; CROSS-tab writes are serialized by the * Web Locks API (the same `navigator.locks` pattern sframe-ratchet's monotonic-idb CTR allocator * uses), and each persist is a read-merge-write so a second tab's accepted CTRs are merged, not * clobbered. CR17-02: when the Web Locks API is absent the read-merge-write cannot be serialized * cross-tab (two tabs could interleave and silently drop a CTR), so durable persistence is gated * OFF entirely (via `available`) rather than run an unlocked RMW — an honest "no durable claim * without Web Locks" posture. The reachable persist path therefore ALWAYS holds the lock. */ export interface DurableReplayGuardOptions { /** Namespace isolating independent key-spaces in the shared IDB store. Default 'default'. */ namespace?: string; /** * Max distinct recent CTRs tracked per (room, sender). Default 1024 (matches the library). * `0` disables the durable window (mirrors sframe-ratchet's `replayWindow: 0`); a negative * value is treated as invalid and falls back to the default (it does NOT disable). */ window?: number; /** Suppress the one-time no-IDB warning (e.g. when the caller has already surfaced it). */ warnIfUnavailable?: boolean; } /** * Durable receiver-side replay window. One instance per provider; state is scoped per * (namespace, roomId, senderUid) so no cross-room / cross-key-space replay-window confusion. * * The caller SHOULD pass a per-tenant `namespace` (the SDK defaults it to the client's `appId`). * Two independent deployments sharing the same origin AND the same namespace (e.g. both omitting * appId → `'default'`) with a colliding (roomId, senderUid) would share a window and could * false-reject each other — give each deployment a distinct namespace/appId to avoid this. */ export declare class DurableReplayGuard { /** True when IndexedDB is present; when false every method is a safe no-op. */ readonly available: boolean; private readonly namespace; private readonly window; private readonly mem; private readonly hydrating; /** * Per-key count of in-flight persists (SEC-CR-F4). A key with a queued/running persist has * an accepted CTR that is NOT yet in the durable store, so evicting it from `mem` and then * re-hydrating from IDB would read a STALE window and false-ACCEPT that very CTR as new. * `trimMemCache` therefore never evicts a key present here. A counter (not a Set) because two * overlapping unseals of one (room, sender) can schedule two persists concurrently. */ private readonly persisting; /** Serializes persist writes so interleaved snapshots cannot clobber each other. */ private persistTail; private warnedPersistFail; private warnedReadFail; constructor(opts?: DurableReplayGuardOptions); private storeKey; /** Load (once) the persisted window for a (room, sender) into the in-memory mirror. */ private hydrate; /** * True if this (room, sender, ctr) has NOT been accepted before — i.e. it is safe to * proceed with AEAD verification. A false result means the CTR was already seen (replay). */ check(roomId: string, senderUid: string, ctr: bigint): Promise; /** * Record an AEAD-authentic CTR as accepted and persist it (write-through). MUST be called * only AFTER a successful unseal, so a forged frame with a novel CTR cannot poison the window. */ accept(roomId: string, senderUid: string, ctr: bigint): Promise; /** Decrement the in-flight-persist count for a key, deleting it at zero (SEC-CR-F4). */ private releasePersisting; /** * SEC-CR-F4 (availability): keep the in-memory `mem` cache bounded by REPLAY_MEM_CACHE_CAP, * evicting the OLDEST evictable (room, sender) entry (insertion-order FIFO). Mirrors * client.ts's `#boundActiveCryptoModeMap`. The durable IDB store is authoritative, so an * evicted entry re-hydrates on next use with no correctness loss. * * Never evicts: * - `justHydratedKey` — the freshest read that just triggered this call; * - a key still in `hydrating` — its mem entry is mid-load; * - a key in `persisting` — it holds an accepted CTR not yet durably written; evicting it * then re-hydrating from IDB would read a stale window and false-accept that CTR (replay). * * If every over-cap entry is protected the loop stops (temporarily over cap); it self-heals * once the in-flight persists settle and `persisting` drains. */ private trimMemCache; /** Drop oldest CTRs until the in-memory window is within bound. */ private trim; /** * Read-merge-write the persisted window under a cross-tab exclusive lock (when available): * union the persisted CTRs (possibly from another tab) with this tab's in-memory window, dedup, * bound, persist, and reflect the union back into the in-memory mirror so this tab immediately * rejects a CTR another tab already accepted. */ private persistMerged; private warnPersistFail; } //# sourceMappingURL=sframe-replay.d.ts.map