OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / auth / replay-cache.js

112 lines · javascript ref:
/**
 * Replay protection for signed envelopes (Phase 2a).
 *
 * In-memory LRU of (pubkey, nonce) pairs seen within the anproto
 * freshness window. A second envelope from the same pubkey with the
 * same nonce — even if the signature verifies and the ts is fresh —
 * is rejected as a replay.
 *
 * This is required because the envelope wire format does NOT use
 * server-issued challenges; freshness is ts-based. Within a 10-minute
 * window, a captured envelope could be replayed unless the server
 * tracks recent (pubkey, nonce) pairs.
 *
 * Memory bound: entries expire after `ttlMs` (default 10 min = 2x the
 * anproto freshness window so we cover both-past and both-future
 * clock skew). Max capacity = `maxEntries` (default 100k) — beyond
 * this we evict oldest.
 *
 * Per-daemon, not shared across daemons. Two daemons receiving the
 * same envelope independently is NOT a replay from either's perspective
 * (it's legitimate multi-delivery) — applications that care about
 * cross-daemon idempotency handle it at the handler level.
 */

export const DEFAULT_TTL_MS = 10 * 60 * 1000 // 10 min
export const DEFAULT_MAX_ENTRIES = 100_000

export class ReplayCache {
  constructor (opts = {}) {
    this._ttl = opts.ttlMs || DEFAULT_TTL_MS
    this._max = opts.maxEntries || DEFAULT_MAX_ENTRIES
    // Map preserves insertion order, so we can evict oldest by iterating.
    this._entries = new Map() // "pubkey:nonce" → expireAt (epoch ms)
    this._sweepInterval = setInterval(() => this._sweep(), Math.max(60_000, this._ttl / 4))
    if (this._sweepInterval.unref) this._sweepInterval.unref()
  }

  /**
   * @param {string} pubkey
   * @param {string} nonce
   * @returns {boolean} true if (pubkey, nonce) seen recently
   */
  seen (pubkey, nonce) {
    const key = pubkey + ':' + nonce
    const expireAt = this._entries.get(key)
    if (expireAt === undefined) return false
    if (expireAt < Date.now()) {
      this._entries.delete(key)
      return false
    }
    return true
  }

  /**
   * Record that (pubkey, nonce) was seen.
   *
   * @param {string} pubkey
   * @param {string} nonce
   * @param {number} [ts]  - anproto ts from the envelope (for TTL anchoring)
   */
  add (pubkey, nonce, ts) {
    const key = pubkey + ':' + nonce
    const anchor = ts || Date.now()
    // Delete-and-re-set preserves LRU-ish ordering: newest entries at the
    // back, oldest at the front of the Map.
    if (this._entries.has(key)) this._entries.delete(key)
    this._entries.set(key, anchor + this._ttl)

    // Evict oldest if over capacity.
    while (this._entries.size > this._max) {
      const firstKey = this._entries.keys().next().value
      if (firstKey === undefined) break
      this._entries.delete(firstKey)
    }
  }

  /** Number of active (unexpired) entries. */
  get size () {
    return this._entries.size
  }

  /** Clear all entries. Useful in tests; don't call in prod. */
  clear () {
    this._entries.clear()
  }

  /** Stop the background sweep timer + release memory. */
  close () {
    if (this._sweepInterval) {
      clearInterval(this._sweepInterval)
      this._sweepInterval = null
    }
    this._entries.clear()
  }

  // --- internals ---

  _sweep () {
    const now = Date.now()
    let removed = 0
    for (const [key, expireAt] of this._entries) {
      if (expireAt < now) {
        this._entries.delete(key)
        removed++
      } else {
        break
      }
    }
    return removed
  }
}