/**
* 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
}
}