@optimystic/db-core 0.22.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +336 -336
- package/dist/src/cluster/structs.d.ts +39 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +24 -0
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/collection.d.ts +17 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +24 -2
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +5 -0
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +7 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/network/i-peer-network.d.ts +16 -0
- package/dist/src/network/i-peer-network.d.ts.map +1 -1
- package/dist/src/network/struct.d.ts +39 -2
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js +18 -0
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +95 -8
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +121 -8
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +1 -1
- package/dist/src/transaction/transaction.js +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +48 -13
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +25 -2
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/package.json +1 -1
- package/src/cluster/membership.ts +85 -85
- package/src/cluster/structs.ts +43 -4
- package/src/cohort-topic/addressing.ts +120 -120
- package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
- package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
- package/src/cohort-topic/antidos/index.ts +5 -5
- package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
- package/src/cohort-topic/antidos/replay-guard.ts +146 -146
- package/src/cohort-topic/antidos/topic-budget.ts +160 -160
- package/src/cohort-topic/antiflood/index.ts +2 -2
- package/src/cohort-topic/antiflood/invariants.ts +108 -108
- package/src/cohort-topic/antiflood/jitter.ts +117 -117
- package/src/cohort-topic/coldstart.ts +237 -237
- package/src/cohort-topic/dmax.ts +88 -88
- package/src/cohort-topic/gossip/bus.ts +254 -254
- package/src/cohort-topic/gossip/index.ts +3 -3
- package/src/cohort-topic/gossip/records.ts +45 -45
- package/src/cohort-topic/gossip/view.ts +91 -91
- package/src/cohort-topic/index.ts +20 -20
- package/src/cohort-topic/load/barometer.ts +134 -134
- package/src/cohort-topic/load/index.ts +1 -1
- package/src/cohort-topic/member-engine.ts +430 -430
- package/src/cohort-topic/membership/index.ts +3 -3
- package/src/cohort-topic/membership/publisher.ts +163 -163
- package/src/cohort-topic/membership/source.ts +41 -41
- package/src/cohort-topic/membership/verifier.ts +461 -461
- package/src/cohort-topic/ports.ts +157 -157
- package/src/cohort-topic/promotion.ts +405 -405
- package/src/cohort-topic/registration/bytes.ts +37 -37
- package/src/cohort-topic/registration/handoff.ts +154 -154
- package/src/cohort-topic/registration/index.ts +6 -6
- package/src/cohort-topic/registration/renewal.ts +495 -495
- package/src/cohort-topic/registration/sharding.ts +61 -61
- package/src/cohort-topic/registration/store.ts +81 -81
- package/src/cohort-topic/registration/types.ts +91 -91
- package/src/cohort-topic/ring-hash.ts +50 -50
- package/src/cohort-topic/service.ts +416 -416
- package/src/cohort-topic/sig/index.ts +2 -2
- package/src/cohort-topic/sig/payloads.ts +59 -59
- package/src/cohort-topic/sig/threshold.ts +64 -64
- package/src/cohort-topic/tiers.ts +74 -74
- package/src/cohort-topic/traffic.ts +233 -233
- package/src/cohort-topic/walk.ts +326 -326
- package/src/cohort-topic/willingness.ts +237 -237
- package/src/cohort-topic/wire/codec.ts +216 -216
- package/src/cohort-topic/wire/index.ts +18 -18
- package/src/cohort-topic/wire/payloads.ts +126 -126
- package/src/cohort-topic/wire/primitives.ts +188 -188
- package/src/cohort-topic/wire/types.ts +475 -475
- package/src/cohort-topic/wire/validate.ts +512 -512
- package/src/collection/collection-type-registry.ts +37 -37
- package/src/collection/collection.ts +25 -2
- package/src/collections/diary/diary.ts +68 -68
- package/src/collections/tree/readme.md +4 -0
- package/src/collections/tree/tree.ts +320 -312
- package/src/matchmaking/capability-filter.ts +45 -45
- package/src/matchmaking/config.ts +98 -98
- package/src/matchmaking/index.ts +21 -21
- package/src/matchmaking/multi-cohort-seeker.ts +234 -234
- package/src/matchmaking/provider.ts +123 -123
- package/src/matchmaking/query-eval.ts +105 -105
- package/src/matchmaking/seeker-walk.ts +127 -127
- package/src/matchmaking/seeker.ts +86 -86
- package/src/matchmaking/topic-anchor.ts +90 -90
- package/src/matchmaking/voting-quorum.ts +394 -394
- package/src/matchmaking/wire.ts +603 -603
- package/src/network/i-peer-network.ts +17 -0
- package/src/network/stale-failure.ts +43 -43
- package/src/network/struct.ts +41 -2
- package/src/network/types.ts +37 -37
- package/src/reactivity/backfill.ts +220 -220
- package/src/reactivity/backpressure.ts +191 -191
- package/src/reactivity/checkpoint.ts +308 -308
- package/src/reactivity/config.ts +172 -172
- package/src/reactivity/dedupe.ts +132 -132
- package/src/reactivity/forwarder.ts +87 -87
- package/src/reactivity/index.ts +34 -34
- package/src/reactivity/notification.ts +123 -123
- package/src/reactivity/policy.ts +79 -79
- package/src/reactivity/push-state.ts +310 -310
- package/src/reactivity/recover.ts +153 -153
- package/src/reactivity/replay-buffer.ts +141 -141
- package/src/reactivity/resume.ts +549 -549
- package/src/reactivity/rotation.ts +415 -415
- package/src/reactivity/subscriber.ts +132 -132
- package/src/reactivity/subscription.ts +66 -66
- package/src/reactivity/topic-anchor.ts +71 -71
- package/src/reactivity/verify.ts +73 -73
- package/src/reactivity/wire-validate.ts +13 -13
- package/src/reactivity/wire.ts +224 -224
- package/src/testing/async-wait.ts +65 -65
- package/src/testing/index.ts +2 -2
- package/src/testing/test-transactor.ts +638 -502
- package/src/transaction/errors.ts +91 -91
- package/src/transaction/operations-hash.ts +196 -196
- package/src/transaction/read-dependency-collector.ts +78 -78
- package/src/transaction/transaction.ts +1 -1
- package/src/transactor/change-notifier.ts +80 -80
- package/src/transactor/index.ts +5 -5
- package/src/transactor/network-transactor.ts +49 -14
- package/src/transactor/transactor-source.ts +25 -2
- package/src/transform/atomic-proxy.ts +92 -92
- package/src/transform/helpers.ts +159 -159
- package/src/utility/backoff.ts +95 -95
- package/src/utility/batch-coordinator.ts +191 -191
- package/dist/src/transaction/context.d.ts +0 -60
- package/dist/src/transaction/context.d.ts.map +0 -1
- package/dist/src/transaction/context.js +0 -91
- package/dist/src/transaction/context.js.map +0 -1
|
@@ -1,210 +1,210 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — per-peer registration rate limiter (anti-DoS).
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 1. A cohort member tracks inbound
|
|
5
|
-
* `RegisterV1` rate per `(sourcePeerId, topicId)` and refuses a source that exceeds
|
|
6
|
-
* `register_rate_per_peer` (default 4 / min). An over-rate registration draws `UnwillingCohort` with
|
|
7
|
-
* an **exponential** `retryAfter`, so a peer hammering one topic at one cohort backs off geometrically
|
|
8
|
-
* — the same capped-doubling curve the willingness back-off uses ({@link backoffRetryMs}).
|
|
9
|
-
*
|
|
10
|
-
* The limiter is a sliding-window counter: it keeps the accept timestamps inside the trailing
|
|
11
|
-
* `windowMs` for each key, admits while the window holds `< ratePerWindow`, and on rejection advances
|
|
12
|
-
* a per-key strike counter that indexes the back-off curve. A run of strikes decays once the source
|
|
13
|
-
* has been quiet for a full window (its window empties and the strike counter resets), so a
|
|
14
|
-
* well-behaved peer is never permanently penalized. Each key's accept history is trimmed to the live
|
|
15
|
-
* window whenever that key is checked, so a key's footprint stays `O(ratePerWindow)`.
|
|
16
|
-
*
|
|
17
|
-
* The `states` map is bounded two complementary ways so a long-running host cannot leak memory and a
|
|
18
|
-
* flood of attacker-chosen keys cannot exhaust it:
|
|
19
|
-
*
|
|
20
|
-
* - **Hard LRU cap (`maxKeys`)** — enforced inline in {@link SlidingWindowRateLimiter.check}. Every
|
|
21
|
-
* check moves its key to the most-recently-checked end (the {@link import("../../utility/lru-map.js").LruMap}
|
|
22
|
-
* delete-then-set trick over `Map` insertion order); when a *new* key would exceed `maxKeys` the
|
|
23
|
-
* least-recently-checked keys are evicted until within cap. Recency is refreshed on **every** check,
|
|
24
|
-
* rejects included, so a source mid-attack (accumulated `strikes` driving back-off) stays at the hot
|
|
25
|
-
* end and is never the eviction victim — only genuinely idle keys age to the cold end.
|
|
26
|
-
* - **Idle-TTL sweep (`idleTtlMs`)** — {@link SlidingWindowRateLimiter.sweep} drops keys not checked
|
|
27
|
-
* within `idleTtlMs`. Driver-called on the host's gossip cadence, it reclaims steady-state footprint
|
|
28
|
-
* proportional to *active* keys.
|
|
29
|
-
*
|
|
30
|
-
* Eviction is penalty-free when a key is dropped only after a full window of quiet: the window logic
|
|
31
|
-
* already forgives a source quiet for a full window (its accepts age out and `strikes` resets), so
|
|
32
|
-
* dropping such a key and re-allocating a fresh `{ accepts:[now], strikes:0 }` on its return is
|
|
33
|
-
* observationally identical to keeping it — just an earlier reclaim. This holds for the LRU cap (a
|
|
34
|
-
* mid-attack key is refreshed to the hot end on every check, so only genuinely idle keys are evicted)
|
|
35
|
-
* and for the default `idleTtlMs` (`== windowMs`). A configured `idleTtlMs < windowMs` reclaims more
|
|
36
|
-
* aggressively: `sweep` may then drop a key whose accepts have *not* fully aged out, forgiving its
|
|
37
|
-
* accumulated `strikes` sooner than the window alone would — an accepted footprint/strike-accounting
|
|
38
|
-
* tradeoff, not an identity. The substrate does not defend against unbounded Sybil key creation (that is FRET's /
|
|
39
|
-
* the reputation subsystem's concern; see §Anti-DoS closing note).
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
import { recordKey } from "../registration/bytes.js";
|
|
43
|
-
import { backoffRetryMs, type BackoffConfig, DEFAULT_BACKOFF_CONFIG } from "../willingness.js";
|
|
44
|
-
import { createLogger } from "../../logger.js";
|
|
45
|
-
|
|
46
|
-
const log = createLogger("cohort-topic:antidos");
|
|
47
|
-
|
|
48
|
-
/** Default per-peer-per-topic acceptance ceiling per window — `register_rate_per_peer` (4 / min). */
|
|
49
|
-
export const DEFAULT_REGISTER_RATE_PER_PEER = 4;
|
|
50
|
-
/** Default sliding window the ceiling applies over (ms). */
|
|
51
|
-
export const DEFAULT_RATE_WINDOW_MS = 60_000;
|
|
52
|
-
/** Default hard cap on tracked `(peer, topic)` keys; the least-recently-checked are evicted beyond this. */
|
|
53
|
-
export const DEFAULT_RATE_LIMITER_MAX_KEYS = 100_000;
|
|
54
|
-
/** Default idle threshold for {@link RegisterRateLimiter.sweep} — one quiet window makes a key evictable. */
|
|
55
|
-
export const DEFAULT_RATE_LIMITER_IDLE_TTL_MS = DEFAULT_RATE_WINDOW_MS;
|
|
56
|
-
|
|
57
|
-
export interface RegisterRateLimiterConfig {
|
|
58
|
-
/** Accepts permitted per window per `(peer, topic)`. Default {@link DEFAULT_REGISTER_RATE_PER_PEER}. */
|
|
59
|
-
ratePerWindow?: number;
|
|
60
|
-
/** Sliding-window length (ms). Default {@link DEFAULT_RATE_WINDOW_MS}. */
|
|
61
|
-
windowMs?: number;
|
|
62
|
-
/** Exponential `retryAfter` curve for over-rate sources. Default {@link DEFAULT_BACKOFF_CONFIG}. */
|
|
63
|
-
backoff?: BackoffConfig;
|
|
64
|
-
/** Hard cap on tracked `(peer, topic)` keys; least-recently-checked evicted beyond this. Default {@link DEFAULT_RATE_LIMITER_MAX_KEYS}. */
|
|
65
|
-
maxKeys?: number;
|
|
66
|
-
/**
|
|
67
|
-
* A key not checked within this many ms is evictable by {@link RegisterRateLimiter.sweep}. Default
|
|
68
|
-
* {@link DEFAULT_RATE_LIMITER_IDLE_TTL_MS} (`== windowMs`). Keep `>= windowMs` to preserve the
|
|
69
|
-
* penalty-free invariant; a smaller value reclaims sooner but may forgive an idle source's strikes
|
|
70
|
-
* before its window would (see the class doc comment).
|
|
71
|
-
*/
|
|
72
|
-
idleTtlMs?: number;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Outcome of a rate check: admitted, or refused with the back-off the caller puts in `UnwillingCohort`. */
|
|
76
|
-
export type RateCheckResult = { ok: true } | { ok: false; retryAfterMs: number };
|
|
77
|
-
|
|
78
|
-
/** Per-peer-per-topic inbound `RegisterV1` rate limiter. */
|
|
79
|
-
export interface RegisterRateLimiter {
|
|
80
|
-
/**
|
|
81
|
-
* Classify a register from `peerId` for `topicId` at `now`. Records the accept on `{ ok: true }`;
|
|
82
|
-
* an over-rate source gets `{ ok: false, retryAfterMs }` with exponential back-off and the strike
|
|
83
|
-
* is *not* recorded as an accept (so back-off cannot itself fill the window). Every call — accept
|
|
84
|
-
* **or** reject — refreshes the key's recency, so an actively-hammering source is never evicted by
|
|
85
|
-
* the LRU cap (which would reset its back-off escalation).
|
|
86
|
-
*/
|
|
87
|
-
check(peerId: Uint8Array, topicId: Uint8Array, now: number): RateCheckResult;
|
|
88
|
-
/**
|
|
89
|
-
* Evict keys idle (not checked) for `>= idleTtlMs`. Returns the number evicted. Driver-called on
|
|
90
|
-
* the host's gossip cadence to reclaim steady-state footprint; the hard `maxKeys` LRU cap bounds
|
|
91
|
-
* worst-case footprint even without it.
|
|
92
|
-
*/
|
|
93
|
-
sweep(now: number): number;
|
|
94
|
-
/** Tracked `(peer, topic)` key count (test/diagnostic introspection). */
|
|
95
|
-
readonly size: number;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Sliding-window state for one `(peer, topic)` key. */
|
|
99
|
-
interface WindowState {
|
|
100
|
-
/** Accept timestamps inside the trailing window, ascending. */
|
|
101
|
-
accepts: number[];
|
|
102
|
-
/** Consecutive over-rate strikes since the last accept — indexes the back-off curve. */
|
|
103
|
-
strikes: number;
|
|
104
|
-
/** `now` of the most recent check (accept OR reject) — LRU recency + idle-TTL key for {@link RegisterRateLimiter.sweep}. */
|
|
105
|
-
lastSeen: number;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
class SlidingWindowRateLimiter implements RegisterRateLimiter {
|
|
109
|
-
private readonly states = new Map<string, WindowState>();
|
|
110
|
-
private readonly ratePerWindow: number;
|
|
111
|
-
private readonly windowMs: number;
|
|
112
|
-
private readonly backoff: BackoffConfig;
|
|
113
|
-
private readonly maxKeys: number;
|
|
114
|
-
private readonly idleTtlMs: number;
|
|
115
|
-
|
|
116
|
-
constructor(config: RegisterRateLimiterConfig = {}) {
|
|
117
|
-
this.ratePerWindow = config.ratePerWindow ?? DEFAULT_REGISTER_RATE_PER_PEER;
|
|
118
|
-
if (!Number.isInteger(this.ratePerWindow) || this.ratePerWindow <= 0) {
|
|
119
|
-
throw new RangeError(`ratePerWindow must be a positive integer, got ${this.ratePerWindow}`);
|
|
120
|
-
}
|
|
121
|
-
this.windowMs = config.windowMs ?? DEFAULT_RATE_WINDOW_MS;
|
|
122
|
-
if (!(this.windowMs > 0)) {
|
|
123
|
-
throw new RangeError(`windowMs must be > 0, got ${this.windowMs}`);
|
|
124
|
-
}
|
|
125
|
-
this.backoff = config.backoff ?? DEFAULT_BACKOFF_CONFIG;
|
|
126
|
-
this.maxKeys = config.maxKeys ?? DEFAULT_RATE_LIMITER_MAX_KEYS;
|
|
127
|
-
if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
|
|
128
|
-
throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
|
|
129
|
-
}
|
|
130
|
-
this.idleTtlMs = config.idleTtlMs ?? DEFAULT_RATE_LIMITER_IDLE_TTL_MS;
|
|
131
|
-
if (!(this.idleTtlMs > 0)) {
|
|
132
|
-
throw new RangeError(`idleTtlMs must be > 0, got ${this.idleTtlMs}`);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
check(peerId: Uint8Array, topicId: Uint8Array, now: number): RateCheckResult {
|
|
137
|
-
const key = recordKey(topicId, peerId);
|
|
138
|
-
const state = this.states.get(key);
|
|
139
|
-
const cutoff = now - this.windowMs;
|
|
140
|
-
|
|
141
|
-
if (state === undefined) {
|
|
142
|
-
// New key: enforce the hard cap by evicting the least-recently-checked keys (oldest by
|
|
143
|
-
// `Map` insertion order) until this insertion stays within `maxKeys`. The evicted keys
|
|
144
|
-
// are by construction the coldest — an actively-checked key is refreshed to the hot end
|
|
145
|
-
// below and so is never the victim.
|
|
146
|
-
while (this.states.size >= this.maxKeys) {
|
|
147
|
-
const oldest = this.states.keys().next().value;
|
|
148
|
-
if (oldest === undefined) break;
|
|
149
|
-
this.states.delete(oldest);
|
|
150
|
-
}
|
|
151
|
-
this.states.set(key, { accepts: [now], strikes: 0, lastSeen: now });
|
|
152
|
-
return { ok: true };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Refresh LRU recency on every check (accept OR reject): delete-then-set moves this key to the
|
|
156
|
-
// most-recently-checked end of the `Map`, and `lastSeen = now` is the idle-TTL key sweep() reads.
|
|
157
|
-
// A source mid-attack stays at the hot end here even on a reject, so the cap never evicts (and
|
|
158
|
-
// thus never resets the back-off of) an active attacker.
|
|
159
|
-
this.states.delete(key);
|
|
160
|
-
this.states.set(key, state);
|
|
161
|
-
state.lastSeen = now;
|
|
162
|
-
|
|
163
|
-
// Drop accepts that have aged out of the trailing window.
|
|
164
|
-
let live = 0;
|
|
165
|
-
for (const t of state.accepts) {
|
|
166
|
-
if (t > cutoff) {
|
|
167
|
-
state.accepts[live++] = t;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
state.accepts.length = live;
|
|
171
|
-
|
|
172
|
-
if (live === 0) {
|
|
173
|
-
// The source has been quiet for a full window — forgive accumulated strikes.
|
|
174
|
-
state.strikes = 0;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (live < this.ratePerWindow) {
|
|
178
|
-
state.accepts.push(now);
|
|
179
|
-
state.strikes = 0;
|
|
180
|
-
return { ok: true };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Over rate: exponential back-off indexed by the strike count, then advance the strike.
|
|
184
|
-
const retryAfterMs = backoffRetryMs(state.strikes, this.backoff);
|
|
185
|
-
state.strikes++;
|
|
186
|
-
log("rate-limit reject window=%d/%d strikes=%d retryAfter=%d", live, this.ratePerWindow, state.strikes, retryAfterMs);
|
|
187
|
-
return { ok: false, retryAfterMs };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
sweep(now: number): number {
|
|
191
|
-
let evicted = 0;
|
|
192
|
-
// Deleting the current key during `Map` iteration is well-defined and does not skip entries.
|
|
193
|
-
for (const [key, state] of this.states) {
|
|
194
|
-
if (now - state.lastSeen >= this.idleTtlMs) {
|
|
195
|
-
this.states.delete(key);
|
|
196
|
-
evicted++;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
return evicted;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
get size(): number {
|
|
203
|
-
return this.states.size;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Build a {@link RegisterRateLimiter} over the configured ceiling, window, and back-off curve. */
|
|
208
|
-
export function createRegisterRateLimiter(config: RegisterRateLimiterConfig = {}): RegisterRateLimiter {
|
|
209
|
-
return new SlidingWindowRateLimiter(config);
|
|
210
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — per-peer registration rate limiter (anti-DoS).
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 1. A cohort member tracks inbound
|
|
5
|
+
* `RegisterV1` rate per `(sourcePeerId, topicId)` and refuses a source that exceeds
|
|
6
|
+
* `register_rate_per_peer` (default 4 / min). An over-rate registration draws `UnwillingCohort` with
|
|
7
|
+
* an **exponential** `retryAfter`, so a peer hammering one topic at one cohort backs off geometrically
|
|
8
|
+
* — the same capped-doubling curve the willingness back-off uses ({@link backoffRetryMs}).
|
|
9
|
+
*
|
|
10
|
+
* The limiter is a sliding-window counter: it keeps the accept timestamps inside the trailing
|
|
11
|
+
* `windowMs` for each key, admits while the window holds `< ratePerWindow`, and on rejection advances
|
|
12
|
+
* a per-key strike counter that indexes the back-off curve. A run of strikes decays once the source
|
|
13
|
+
* has been quiet for a full window (its window empties and the strike counter resets), so a
|
|
14
|
+
* well-behaved peer is never permanently penalized. Each key's accept history is trimmed to the live
|
|
15
|
+
* window whenever that key is checked, so a key's footprint stays `O(ratePerWindow)`.
|
|
16
|
+
*
|
|
17
|
+
* The `states` map is bounded two complementary ways so a long-running host cannot leak memory and a
|
|
18
|
+
* flood of attacker-chosen keys cannot exhaust it:
|
|
19
|
+
*
|
|
20
|
+
* - **Hard LRU cap (`maxKeys`)** — enforced inline in {@link SlidingWindowRateLimiter.check}. Every
|
|
21
|
+
* check moves its key to the most-recently-checked end (the {@link import("../../utility/lru-map.js").LruMap}
|
|
22
|
+
* delete-then-set trick over `Map` insertion order); when a *new* key would exceed `maxKeys` the
|
|
23
|
+
* least-recently-checked keys are evicted until within cap. Recency is refreshed on **every** check,
|
|
24
|
+
* rejects included, so a source mid-attack (accumulated `strikes` driving back-off) stays at the hot
|
|
25
|
+
* end and is never the eviction victim — only genuinely idle keys age to the cold end.
|
|
26
|
+
* - **Idle-TTL sweep (`idleTtlMs`)** — {@link SlidingWindowRateLimiter.sweep} drops keys not checked
|
|
27
|
+
* within `idleTtlMs`. Driver-called on the host's gossip cadence, it reclaims steady-state footprint
|
|
28
|
+
* proportional to *active* keys.
|
|
29
|
+
*
|
|
30
|
+
* Eviction is penalty-free when a key is dropped only after a full window of quiet: the window logic
|
|
31
|
+
* already forgives a source quiet for a full window (its accepts age out and `strikes` resets), so
|
|
32
|
+
* dropping such a key and re-allocating a fresh `{ accepts:[now], strikes:0 }` on its return is
|
|
33
|
+
* observationally identical to keeping it — just an earlier reclaim. This holds for the LRU cap (a
|
|
34
|
+
* mid-attack key is refreshed to the hot end on every check, so only genuinely idle keys are evicted)
|
|
35
|
+
* and for the default `idleTtlMs` (`== windowMs`). A configured `idleTtlMs < windowMs` reclaims more
|
|
36
|
+
* aggressively: `sweep` may then drop a key whose accepts have *not* fully aged out, forgiving its
|
|
37
|
+
* accumulated `strikes` sooner than the window alone would — an accepted footprint/strike-accounting
|
|
38
|
+
* tradeoff, not an identity. The substrate does not defend against unbounded Sybil key creation (that is FRET's /
|
|
39
|
+
* the reputation subsystem's concern; see §Anti-DoS closing note).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { recordKey } from "../registration/bytes.js";
|
|
43
|
+
import { backoffRetryMs, type BackoffConfig, DEFAULT_BACKOFF_CONFIG } from "../willingness.js";
|
|
44
|
+
import { createLogger } from "../../logger.js";
|
|
45
|
+
|
|
46
|
+
const log = createLogger("cohort-topic:antidos");
|
|
47
|
+
|
|
48
|
+
/** Default per-peer-per-topic acceptance ceiling per window — `register_rate_per_peer` (4 / min). */
|
|
49
|
+
export const DEFAULT_REGISTER_RATE_PER_PEER = 4;
|
|
50
|
+
/** Default sliding window the ceiling applies over (ms). */
|
|
51
|
+
export const DEFAULT_RATE_WINDOW_MS = 60_000;
|
|
52
|
+
/** Default hard cap on tracked `(peer, topic)` keys; the least-recently-checked are evicted beyond this. */
|
|
53
|
+
export const DEFAULT_RATE_LIMITER_MAX_KEYS = 100_000;
|
|
54
|
+
/** Default idle threshold for {@link RegisterRateLimiter.sweep} — one quiet window makes a key evictable. */
|
|
55
|
+
export const DEFAULT_RATE_LIMITER_IDLE_TTL_MS = DEFAULT_RATE_WINDOW_MS;
|
|
56
|
+
|
|
57
|
+
export interface RegisterRateLimiterConfig {
|
|
58
|
+
/** Accepts permitted per window per `(peer, topic)`. Default {@link DEFAULT_REGISTER_RATE_PER_PEER}. */
|
|
59
|
+
ratePerWindow?: number;
|
|
60
|
+
/** Sliding-window length (ms). Default {@link DEFAULT_RATE_WINDOW_MS}. */
|
|
61
|
+
windowMs?: number;
|
|
62
|
+
/** Exponential `retryAfter` curve for over-rate sources. Default {@link DEFAULT_BACKOFF_CONFIG}. */
|
|
63
|
+
backoff?: BackoffConfig;
|
|
64
|
+
/** Hard cap on tracked `(peer, topic)` keys; least-recently-checked evicted beyond this. Default {@link DEFAULT_RATE_LIMITER_MAX_KEYS}. */
|
|
65
|
+
maxKeys?: number;
|
|
66
|
+
/**
|
|
67
|
+
* A key not checked within this many ms is evictable by {@link RegisterRateLimiter.sweep}. Default
|
|
68
|
+
* {@link DEFAULT_RATE_LIMITER_IDLE_TTL_MS} (`== windowMs`). Keep `>= windowMs` to preserve the
|
|
69
|
+
* penalty-free invariant; a smaller value reclaims sooner but may forgive an idle source's strikes
|
|
70
|
+
* before its window would (see the class doc comment).
|
|
71
|
+
*/
|
|
72
|
+
idleTtlMs?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Outcome of a rate check: admitted, or refused with the back-off the caller puts in `UnwillingCohort`. */
|
|
76
|
+
export type RateCheckResult = { ok: true } | { ok: false; retryAfterMs: number };
|
|
77
|
+
|
|
78
|
+
/** Per-peer-per-topic inbound `RegisterV1` rate limiter. */
|
|
79
|
+
export interface RegisterRateLimiter {
|
|
80
|
+
/**
|
|
81
|
+
* Classify a register from `peerId` for `topicId` at `now`. Records the accept on `{ ok: true }`;
|
|
82
|
+
* an over-rate source gets `{ ok: false, retryAfterMs }` with exponential back-off and the strike
|
|
83
|
+
* is *not* recorded as an accept (so back-off cannot itself fill the window). Every call — accept
|
|
84
|
+
* **or** reject — refreshes the key's recency, so an actively-hammering source is never evicted by
|
|
85
|
+
* the LRU cap (which would reset its back-off escalation).
|
|
86
|
+
*/
|
|
87
|
+
check(peerId: Uint8Array, topicId: Uint8Array, now: number): RateCheckResult;
|
|
88
|
+
/**
|
|
89
|
+
* Evict keys idle (not checked) for `>= idleTtlMs`. Returns the number evicted. Driver-called on
|
|
90
|
+
* the host's gossip cadence to reclaim steady-state footprint; the hard `maxKeys` LRU cap bounds
|
|
91
|
+
* worst-case footprint even without it.
|
|
92
|
+
*/
|
|
93
|
+
sweep(now: number): number;
|
|
94
|
+
/** Tracked `(peer, topic)` key count (test/diagnostic introspection). */
|
|
95
|
+
readonly size: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Sliding-window state for one `(peer, topic)` key. */
|
|
99
|
+
interface WindowState {
|
|
100
|
+
/** Accept timestamps inside the trailing window, ascending. */
|
|
101
|
+
accepts: number[];
|
|
102
|
+
/** Consecutive over-rate strikes since the last accept — indexes the back-off curve. */
|
|
103
|
+
strikes: number;
|
|
104
|
+
/** `now` of the most recent check (accept OR reject) — LRU recency + idle-TTL key for {@link RegisterRateLimiter.sweep}. */
|
|
105
|
+
lastSeen: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class SlidingWindowRateLimiter implements RegisterRateLimiter {
|
|
109
|
+
private readonly states = new Map<string, WindowState>();
|
|
110
|
+
private readonly ratePerWindow: number;
|
|
111
|
+
private readonly windowMs: number;
|
|
112
|
+
private readonly backoff: BackoffConfig;
|
|
113
|
+
private readonly maxKeys: number;
|
|
114
|
+
private readonly idleTtlMs: number;
|
|
115
|
+
|
|
116
|
+
constructor(config: RegisterRateLimiterConfig = {}) {
|
|
117
|
+
this.ratePerWindow = config.ratePerWindow ?? DEFAULT_REGISTER_RATE_PER_PEER;
|
|
118
|
+
if (!Number.isInteger(this.ratePerWindow) || this.ratePerWindow <= 0) {
|
|
119
|
+
throw new RangeError(`ratePerWindow must be a positive integer, got ${this.ratePerWindow}`);
|
|
120
|
+
}
|
|
121
|
+
this.windowMs = config.windowMs ?? DEFAULT_RATE_WINDOW_MS;
|
|
122
|
+
if (!(this.windowMs > 0)) {
|
|
123
|
+
throw new RangeError(`windowMs must be > 0, got ${this.windowMs}`);
|
|
124
|
+
}
|
|
125
|
+
this.backoff = config.backoff ?? DEFAULT_BACKOFF_CONFIG;
|
|
126
|
+
this.maxKeys = config.maxKeys ?? DEFAULT_RATE_LIMITER_MAX_KEYS;
|
|
127
|
+
if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
|
|
128
|
+
throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
|
|
129
|
+
}
|
|
130
|
+
this.idleTtlMs = config.idleTtlMs ?? DEFAULT_RATE_LIMITER_IDLE_TTL_MS;
|
|
131
|
+
if (!(this.idleTtlMs > 0)) {
|
|
132
|
+
throw new RangeError(`idleTtlMs must be > 0, got ${this.idleTtlMs}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
check(peerId: Uint8Array, topicId: Uint8Array, now: number): RateCheckResult {
|
|
137
|
+
const key = recordKey(topicId, peerId);
|
|
138
|
+
const state = this.states.get(key);
|
|
139
|
+
const cutoff = now - this.windowMs;
|
|
140
|
+
|
|
141
|
+
if (state === undefined) {
|
|
142
|
+
// New key: enforce the hard cap by evicting the least-recently-checked keys (oldest by
|
|
143
|
+
// `Map` insertion order) until this insertion stays within `maxKeys`. The evicted keys
|
|
144
|
+
// are by construction the coldest — an actively-checked key is refreshed to the hot end
|
|
145
|
+
// below and so is never the victim.
|
|
146
|
+
while (this.states.size >= this.maxKeys) {
|
|
147
|
+
const oldest = this.states.keys().next().value;
|
|
148
|
+
if (oldest === undefined) break;
|
|
149
|
+
this.states.delete(oldest);
|
|
150
|
+
}
|
|
151
|
+
this.states.set(key, { accepts: [now], strikes: 0, lastSeen: now });
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Refresh LRU recency on every check (accept OR reject): delete-then-set moves this key to the
|
|
156
|
+
// most-recently-checked end of the `Map`, and `lastSeen = now` is the idle-TTL key sweep() reads.
|
|
157
|
+
// A source mid-attack stays at the hot end here even on a reject, so the cap never evicts (and
|
|
158
|
+
// thus never resets the back-off of) an active attacker.
|
|
159
|
+
this.states.delete(key);
|
|
160
|
+
this.states.set(key, state);
|
|
161
|
+
state.lastSeen = now;
|
|
162
|
+
|
|
163
|
+
// Drop accepts that have aged out of the trailing window.
|
|
164
|
+
let live = 0;
|
|
165
|
+
for (const t of state.accepts) {
|
|
166
|
+
if (t > cutoff) {
|
|
167
|
+
state.accepts[live++] = t;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
state.accepts.length = live;
|
|
171
|
+
|
|
172
|
+
if (live === 0) {
|
|
173
|
+
// The source has been quiet for a full window — forgive accumulated strikes.
|
|
174
|
+
state.strikes = 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (live < this.ratePerWindow) {
|
|
178
|
+
state.accepts.push(now);
|
|
179
|
+
state.strikes = 0;
|
|
180
|
+
return { ok: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Over rate: exponential back-off indexed by the strike count, then advance the strike.
|
|
184
|
+
const retryAfterMs = backoffRetryMs(state.strikes, this.backoff);
|
|
185
|
+
state.strikes++;
|
|
186
|
+
log("rate-limit reject window=%d/%d strikes=%d retryAfter=%d", live, this.ratePerWindow, state.strikes, retryAfterMs);
|
|
187
|
+
return { ok: false, retryAfterMs };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
sweep(now: number): number {
|
|
191
|
+
let evicted = 0;
|
|
192
|
+
// Deleting the current key during `Map` iteration is well-defined and does not skip entries.
|
|
193
|
+
for (const [key, state] of this.states) {
|
|
194
|
+
if (now - state.lastSeen >= this.idleTtlMs) {
|
|
195
|
+
this.states.delete(key);
|
|
196
|
+
evicted++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return evicted;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
get size(): number {
|
|
203
|
+
return this.states.size;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Build a {@link RegisterRateLimiter} over the configured ceiling, window, and back-off curve. */
|
|
208
|
+
export function createRegisterRateLimiter(config: RegisterRateLimiterConfig = {}): RegisterRateLimiter {
|
|
209
|
+
return new SlidingWindowRateLimiter(config);
|
|
210
|
+
}
|