@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.
Files changed (141) hide show
  1. package/README.md +336 -336
  2. package/dist/src/cluster/structs.d.ts +39 -1
  3. package/dist/src/cluster/structs.d.ts.map +1 -1
  4. package/dist/src/cluster/structs.js +24 -0
  5. package/dist/src/cluster/structs.js.map +1 -1
  6. package/dist/src/collection/collection.d.ts +17 -0
  7. package/dist/src/collection/collection.d.ts.map +1 -1
  8. package/dist/src/collection/collection.js +24 -2
  9. package/dist/src/collection/collection.js.map +1 -1
  10. package/dist/src/collections/tree/tree.d.ts +5 -0
  11. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  12. package/dist/src/collections/tree/tree.js +7 -0
  13. package/dist/src/collections/tree/tree.js.map +1 -1
  14. package/dist/src/network/i-peer-network.d.ts +16 -0
  15. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  16. package/dist/src/network/struct.d.ts +39 -2
  17. package/dist/src/network/struct.d.ts.map +1 -1
  18. package/dist/src/network/struct.js +18 -0
  19. package/dist/src/network/struct.js.map +1 -1
  20. package/dist/src/testing/test-transactor.d.ts +95 -8
  21. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  22. package/dist/src/testing/test-transactor.js +121 -8
  23. package/dist/src/testing/test-transactor.js.map +1 -1
  24. package/dist/src/transaction/transaction.d.ts +1 -1
  25. package/dist/src/transaction/transaction.js +1 -1
  26. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  27. package/dist/src/transactor/network-transactor.js +48 -13
  28. package/dist/src/transactor/network-transactor.js.map +1 -1
  29. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  30. package/dist/src/transactor/transactor-source.js +25 -2
  31. package/dist/src/transactor/transactor-source.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cluster/membership.ts +85 -85
  34. package/src/cluster/structs.ts +43 -4
  35. package/src/cohort-topic/addressing.ts +120 -120
  36. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  37. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  38. package/src/cohort-topic/antidos/index.ts +5 -5
  39. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  40. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  41. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  42. package/src/cohort-topic/antiflood/index.ts +2 -2
  43. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  44. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  45. package/src/cohort-topic/coldstart.ts +237 -237
  46. package/src/cohort-topic/dmax.ts +88 -88
  47. package/src/cohort-topic/gossip/bus.ts +254 -254
  48. package/src/cohort-topic/gossip/index.ts +3 -3
  49. package/src/cohort-topic/gossip/records.ts +45 -45
  50. package/src/cohort-topic/gossip/view.ts +91 -91
  51. package/src/cohort-topic/index.ts +20 -20
  52. package/src/cohort-topic/load/barometer.ts +134 -134
  53. package/src/cohort-topic/load/index.ts +1 -1
  54. package/src/cohort-topic/member-engine.ts +430 -430
  55. package/src/cohort-topic/membership/index.ts +3 -3
  56. package/src/cohort-topic/membership/publisher.ts +163 -163
  57. package/src/cohort-topic/membership/source.ts +41 -41
  58. package/src/cohort-topic/membership/verifier.ts +461 -461
  59. package/src/cohort-topic/ports.ts +157 -157
  60. package/src/cohort-topic/promotion.ts +405 -405
  61. package/src/cohort-topic/registration/bytes.ts +37 -37
  62. package/src/cohort-topic/registration/handoff.ts +154 -154
  63. package/src/cohort-topic/registration/index.ts +6 -6
  64. package/src/cohort-topic/registration/renewal.ts +495 -495
  65. package/src/cohort-topic/registration/sharding.ts +61 -61
  66. package/src/cohort-topic/registration/store.ts +81 -81
  67. package/src/cohort-topic/registration/types.ts +91 -91
  68. package/src/cohort-topic/ring-hash.ts +50 -50
  69. package/src/cohort-topic/service.ts +416 -416
  70. package/src/cohort-topic/sig/index.ts +2 -2
  71. package/src/cohort-topic/sig/payloads.ts +59 -59
  72. package/src/cohort-topic/sig/threshold.ts +64 -64
  73. package/src/cohort-topic/tiers.ts +74 -74
  74. package/src/cohort-topic/traffic.ts +233 -233
  75. package/src/cohort-topic/walk.ts +326 -326
  76. package/src/cohort-topic/willingness.ts +237 -237
  77. package/src/cohort-topic/wire/codec.ts +216 -216
  78. package/src/cohort-topic/wire/index.ts +18 -18
  79. package/src/cohort-topic/wire/payloads.ts +126 -126
  80. package/src/cohort-topic/wire/primitives.ts +188 -188
  81. package/src/cohort-topic/wire/types.ts +475 -475
  82. package/src/cohort-topic/wire/validate.ts +512 -512
  83. package/src/collection/collection-type-registry.ts +37 -37
  84. package/src/collection/collection.ts +25 -2
  85. package/src/collections/diary/diary.ts +68 -68
  86. package/src/collections/tree/readme.md +4 -0
  87. package/src/collections/tree/tree.ts +320 -312
  88. package/src/matchmaking/capability-filter.ts +45 -45
  89. package/src/matchmaking/config.ts +98 -98
  90. package/src/matchmaking/index.ts +21 -21
  91. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  92. package/src/matchmaking/provider.ts +123 -123
  93. package/src/matchmaking/query-eval.ts +105 -105
  94. package/src/matchmaking/seeker-walk.ts +127 -127
  95. package/src/matchmaking/seeker.ts +86 -86
  96. package/src/matchmaking/topic-anchor.ts +90 -90
  97. package/src/matchmaking/voting-quorum.ts +394 -394
  98. package/src/matchmaking/wire.ts +603 -603
  99. package/src/network/i-peer-network.ts +17 -0
  100. package/src/network/stale-failure.ts +43 -43
  101. package/src/network/struct.ts +41 -2
  102. package/src/network/types.ts +37 -37
  103. package/src/reactivity/backfill.ts +220 -220
  104. package/src/reactivity/backpressure.ts +191 -191
  105. package/src/reactivity/checkpoint.ts +308 -308
  106. package/src/reactivity/config.ts +172 -172
  107. package/src/reactivity/dedupe.ts +132 -132
  108. package/src/reactivity/forwarder.ts +87 -87
  109. package/src/reactivity/index.ts +34 -34
  110. package/src/reactivity/notification.ts +123 -123
  111. package/src/reactivity/policy.ts +79 -79
  112. package/src/reactivity/push-state.ts +310 -310
  113. package/src/reactivity/recover.ts +153 -153
  114. package/src/reactivity/replay-buffer.ts +141 -141
  115. package/src/reactivity/resume.ts +549 -549
  116. package/src/reactivity/rotation.ts +415 -415
  117. package/src/reactivity/subscriber.ts +132 -132
  118. package/src/reactivity/subscription.ts +66 -66
  119. package/src/reactivity/topic-anchor.ts +71 -71
  120. package/src/reactivity/verify.ts +73 -73
  121. package/src/reactivity/wire-validate.ts +13 -13
  122. package/src/reactivity/wire.ts +224 -224
  123. package/src/testing/async-wait.ts +65 -65
  124. package/src/testing/index.ts +2 -2
  125. package/src/testing/test-transactor.ts +638 -502
  126. package/src/transaction/errors.ts +91 -91
  127. package/src/transaction/operations-hash.ts +196 -196
  128. package/src/transaction/read-dependency-collector.ts +78 -78
  129. package/src/transaction/transaction.ts +1 -1
  130. package/src/transactor/change-notifier.ts +80 -80
  131. package/src/transactor/index.ts +5 -5
  132. package/src/transactor/network-transactor.ts +49 -14
  133. package/src/transactor/transactor-source.ts +25 -2
  134. package/src/transform/atomic-proxy.ts +92 -92
  135. package/src/transform/helpers.ts +159 -159
  136. package/src/utility/backoff.ts +95 -95
  137. package/src/utility/batch-coordinator.ts +191 -191
  138. package/dist/src/transaction/context.d.ts +0 -60
  139. package/dist/src/transaction/context.d.ts.map +0 -1
  140. package/dist/src/transaction/context.js +0 -91
  141. package/dist/src/transaction/context.js.map +0 -1
@@ -1,461 +1,461 @@
1
- /**
2
- * Cohort-topic substrate — participant-side membership verification with a trust-anchor gate.
3
- *
4
- * Per `docs/cohort-topic.md` §Membership snapshots and §Bootstrapping trust. A participant verifying
5
- * a threshold-signed message:
6
- *
7
- * 1. takes the message's `signers`, the cohort `coord` the signers should belong to, and the tier;
8
- * 2. looks up the cached `MembershipCertV1` for that coord (or pulls the source's `current`);
9
- * 3. checks the signers are a `≥ minSigs` subset of the cert's members and the signature verifies;
10
- * 4. **on failure against a cached/stale cert, re-fetches the cert from any cohort member exactly
11
- * once and retries**; still failing → the message is untrusted.
12
- *
13
- * **Trust anchoring (the gate this module adds).** Self-consistency (a cert's own threshold signature
14
- * is a `≥ minSigs` quorum over its own `members`) proves only internal well-formedness — *not* that the
15
- * attesting key set is the legitimate cohort for the coord. An adversary controlling `k − x` keys could
16
- * mint a self-consistent cert over a coord it does not own. So before a (re)fetched cert is believed,
17
- * {@link CachingMembershipVerifier.certIsTrusted} requires it to be self-consistent **AND** anchored by
18
- * at least one of:
19
- *
20
- * - **Trust root** — `(coord, epoch, member-set)` is in the out-of-band-seeded {@link TrustRoot} set
21
- * (the genesis-block cohorts). Base case of every chain; checked before the direct anchor, so a
22
- * configured root is authoritative.
23
- * - **Direct anchor** — the injected {@link IMembershipTrustAnchor} vouches for the binding from a
24
- * source the node directly trusts (FRET ring agreement / tx-log commit cert, bound in db-p2p). A
25
- * `"rejected"` verdict is **fatal** (a forgery, even if self-consistent) and overrides the fallback.
26
- * - **Attestation chain** — a cert carrying a rotation attestation (`prevEpoch`/`rotationSig`/
27
- * `rotationSigners`) inherits trust when the node already holds a **trusted** predecessor for the same
28
- * coord at `prevEpoch` whose members form a `≥ minSigs` quorum over this cert's signing payload.
29
- *
30
- * **Interim TOFU fallback (documented limit).** For a coord the node cannot anchor (the direct anchor
31
- * returns `"unknown"` and there is no trust root / chain) and that has **no trusted cert yet**, the
32
- * verifier falls back to trust-on-first-use of any self-consistent cert — identical to the pre-anchor
33
- * behavior, so there is strictly no regression on coords no node can verify today (distant T2/T3, and
34
- * T0/T1 until the committed-index binding lands). Once a coord *does* hold a trusted cert, the chain
35
- * governs successors: an un-anchored cert for an already-trusted coord is rejected (no TOFU downgrade),
36
- * which is what gives the rotation chain its teeth. The FRET / tx-log direct-anchor bindings that close
37
- * the remaining TOFU gap are tracked in `cohort-topic-trust-anchor-fret-binding` and the backlog
38
- * `...-fret-stabilization-proof` / `...-txlog-committed-binding` tickets.
39
- *
40
- * **Deviation from the ticket sketch (documented):** `verifyMessage` takes the cohort `tier`. A coord
41
- * is an opaque hash, so the T0/T1-vs-T2/T3 source dispatch the same ticket mandates cannot be derived
42
- * from the coord alone; the caller already knows the tier (it computed the coord from the message's
43
- * claimed tier/topic). The `tier` is threaded into the gate so the direct anchor is consulted with the
44
- * same tier the router used (the binding is tier-scoped).
45
- */
46
-
47
- import type { IMembershipTrustAnchor, RingCoord, TrustRoot } from "../ports.js";
48
- import { noAuthorityTrustAnchor } from "../ports.js";
49
- import { b64urlToBytes, bytesToB64url, decodeMembershipCertV1 } from "../wire/codec.js";
50
- import { CohortWireError } from "../wire/validate.js";
51
- import type { MembershipCertV1 } from "../wire/types.js";
52
- import { DEFAULT_MIN_SIGS, type CohortSigner } from "../sig/threshold.js";
53
- import { membershipCertSigningPayload } from "../sig/payloads.js";
54
- import type { IMembershipSourceRouter } from "./source.js";
55
- import { LruMap } from "../../utility/lru-map.js";
56
-
57
- /**
58
- * Default hard cap on distinct coords the verifier's per-coord maps retain (`byCoord`, `lastFetchAt`,
59
- * `staleGapStrikes`). Beyond it the least-recently-used coord is evicted — bounding memory under a flood
60
- * of verify-misses against attacker-chosen coords. Same 100k ballpark as the sibling anti-DoS caps
61
- * ({@link import("../antidos/replay-guard.js").DEFAULT_REPLAY_GUARD_MAX_KEYS}).
62
- */
63
- export const DEFAULT_MEMBERSHIP_VERIFIER_MAX_COORDS = 100_000;
64
-
65
- /** Outcome of verifying a threshold-signed message against cohort membership. */
66
- export type VerifyResult = "verified" | "untrusted";
67
-
68
- /** Caches certs per coord and verifies threshold-signed messages with one stale-cert refetch. */
69
- export interface MembershipVerifier {
70
- /** Cache `cert` as the latest known membership for its coord. */
71
- cache(cert: MembershipCertV1): void;
72
- /**
73
- * Verify a threshold-signed message. `expectedCoord` is the cohort the `signers` should belong to;
74
- * `tier` selects the membership source. Performs the single refetch+retry internally.
75
- *
76
- * `opts` bounds the network amplification a flood-exposed caller (the `promote` handler) can suffer:
77
- * when **both** `minRefetchIntervalMs` and `now` are given, the stale-cert `source.fetch()` retry is
78
- * **rate-limited per coord** — at most one refetch per coord per interval — so a stream of verify-misses
79
- * (e.g. forged notices) drives a bounded membership-fetch rate rather than one dial per message.
80
- * Eventual refetch is *preserved*: a cold cache or a membership rotation still re-fetches once the
81
- * interval elapses (unlike outright suppression). Omit `opts` (the default, and every existing caller)
82
- * for the unbounded exactly-one-refetch behavior.
83
- */
84
- verifyMessage(signers: readonly Uint8Array[], expectedCoord: RingCoord, tier: number, payload: Uint8Array, sig: Uint8Array, opts?: RefetchBound): Promise<VerifyResult>;
85
- }
86
-
87
- /**
88
- * Caller-supplied bound on the membership-cert refetch rate (anti-amplification on a flood-exposed verify
89
- * path). Both fields are required to take effect; omit either for unbounded refetch.
90
- */
91
- export interface RefetchBound {
92
- /** Minimum wall-clock gap (ms) between `source.fetch()` refetches for the same coord. */
93
- readonly minRefetchIntervalMs?: number;
94
- /** Current wall clock (ms) for the interval comparison. */
95
- readonly now?: number;
96
- }
97
-
98
- export interface MembershipVerifierDeps {
99
- signer: CohortSigner;
100
- router: IMembershipSourceRouter;
101
- minSigs?: number;
102
- maxMessageBytes?: number;
103
- /**
104
- * Direct (base-case) trust anchor for a cert's `coord → keyset` binding. Defaults to
105
- * {@link noAuthorityTrustAnchor} (every coord `"unknown"`), which preserves the interim TOFU behavior.
106
- * db-p2p injects the FRET-ring-backed anchor.
107
- */
108
- anchor?: IMembershipTrustAnchor;
109
- /** Out-of-band-seeded genesis trust roots (the base case of every attestation chain). Defaults to `[]`. */
110
- trustRoots?: readonly TrustRoot[];
111
- /**
112
- * Number of **consecutive** gap-signalled refetches after which a **trust-locked** coord whose direct
113
- * anchor has gone `"unknown"` re-enters the interim TOFU regime — the exit from a stale trust-lock a
114
- * former cohort member would otherwise be stranded in until the host process restarts.
115
- *
116
- * The lock (a coord holding a *trusted* cached cert refuses any un-anchored refetch — no TOFU downgrade)
117
- * has no other exit: a node that served coord `C`, self-published its cert (locking `C`), then left `C`'s
118
- * cohort keeps distrusting every later-epoch message from `C` if it missed an intermediate rotation, since
119
- * the refetched cert's `prevEpoch` no longer matches the stale cached epoch and the anchor no longer
120
- * vouches for `C`. Recovery counts **only** refetched certs presenting an *explicit chain gap* — a full
121
- * rotation attestation whose `prevEpoch ≠` the cached trusted epoch (the network provably rotated past the
122
- * cached epoch through an epoch this node never witnessed). A forged rotation off the *current* cached
123
- * predecessor (`prevEpoch == cachedEpoch`) never counts as a strike, so the lock's headline invariant
124
- * (un-anchored successor of a matching predecessor stays rejected) is preserved.
125
- *
126
- * Defaults to `3`. Setting it to `0` (or a negative) **disables** recovery — which re-opens the stale-lock
127
- * liveness bug, so leave it on unless a caller has an independent lock-drop mechanism (see the
128
- * drop-the-lock-on-demotion tripwire in the ticket).
129
- */
130
- staleGapRecoveryStrikes?: number;
131
- /**
132
- * Hard LRU cap on distinct coords retained across the verifier's per-coord maps (`byCoord`,
133
- * `lastFetchAt`, `staleGapStrikes`); the least-recently-used coord is evicted beyond it. Bounds memory
134
- * under a flood of verify-misses against attacker-chosen coords. Defaults to
135
- * {@link DEFAULT_MEMBERSHIP_VERIFIER_MAX_COORDS}; must be a positive integer (mirrors the sibling
136
- * anti-DoS `maxKeys` guard).
137
- */
138
- maxCoords?: number;
139
- }
140
-
141
- /** Result of the trust gate: accept as a trusted anchor, accept as interim TOFU, or reject outright. */
142
- type CertTrust = "trusted" | "tofu" | "reject";
143
-
144
- /** A cached cert and whether it is *trusted* (may serve as an attestation-chain predecessor). */
145
- interface CachedCert {
146
- cert: MembershipCertV1;
147
- /** True only when the cert passed via trust-root / direct-anchor / chain, or was self-published (`cache`). */
148
- trusted: boolean;
149
- }
150
-
151
- /** A {@link TrustRoot} pre-normalized to the cert's base64url form for cheap matching. */
152
- interface NormalizedTrustRoot {
153
- coord: string;
154
- epoch: string;
155
- members: ReadonlySet<string>;
156
- }
157
-
158
- class CachingMembershipVerifier implements MembershipVerifier {
159
- // LRU-capped so a flood of verify-misses against attacker-chosen coords cannot grow these maps without
160
- // bound (coordKey is base64url of an attacker-derivable RingCoord). Each is capped INDEPENDENTLY: the
161
- // aux maps may briefly retain a coord `byCoord` already evicted — harmless (a stale `lastFetchAt` at
162
- // worst permits one extra refetch; a stale strike count is itself bounded).
163
- // NOTE: `byCoord` also holds this node's OWN `cache()`-published trusted cert (the trust lock). Under an
164
- // attacker-coord flood the LRU can evict a trusted self-published entry, re-opening that coord to TOFU on
165
- // next sight — the trust lock is best-effort under memory pressure. This mirrors the documented penalty
166
- // tradeoff on the replay-guard cap and is acceptable; do not file it as a separate ticket.
167
- private readonly byCoord: LruMap<string, CachedCert>;
168
- /** Per-coord timestamp of the last `source.fetch()` attempt (the rate-limit clock for {@link RefetchBound}). */
169
- private readonly lastFetchAt: LruMap<string, number>;
170
- /**
171
- * Per-coord count of *consecutive* gap-signalled refetches against a trust-locked coord (base64url key).
172
- * Reset to zero whenever a message verifies for the coord (see {@link verifyMessage}); at
173
- * {@link staleGapRecoveryStrikes} the lock is released back to TOFU (see {@link staleGapRecovery}).
174
- */
175
- private readonly staleGapStrikes: LruMap<string, number>;
176
- private readonly minSigs: number;
177
- private readonly anchor: IMembershipTrustAnchor;
178
- private readonly trustRoots: readonly NormalizedTrustRoot[];
179
- private readonly staleGapRecoveryStrikes: number;
180
-
181
- constructor(private readonly deps: MembershipVerifierDeps) {
182
- this.minSigs = deps.minSigs ?? DEFAULT_MIN_SIGS;
183
- this.anchor = deps.anchor ?? noAuthorityTrustAnchor;
184
- this.trustRoots = (deps.trustRoots ?? []).map(normalizeTrustRoot);
185
- this.staleGapRecoveryStrikes = deps.staleGapRecoveryStrikes ?? 3;
186
- const maxCoords = deps.maxCoords ?? DEFAULT_MEMBERSHIP_VERIFIER_MAX_COORDS;
187
- if (!Number.isInteger(maxCoords) || maxCoords <= 0) {
188
- throw new RangeError(`maxCoords must be a positive integer, got ${maxCoords}`);
189
- }
190
- this.byCoord = new LruMap(maxCoords);
191
- this.lastFetchAt = new LruMap(maxCoords);
192
- this.staleGapStrikes = new LruMap(maxCoords);
193
- }
194
-
195
- cache(cert: MembershipCertV1): void {
196
- // The public cache feeds this node its OWN freshly-published cert — a node trusts a cert it itself
197
- // published, so it is marked trusted and may anchor the next rotation in the attestation chain.
198
- this.byCoord.set(cert.cohortCoord, { cert, trusted: true });
199
- }
200
-
201
- async verifyMessage(signers: readonly Uint8Array[], expectedCoord: RingCoord, tier: number, payload: Uint8Array, sig: Uint8Array, opts?: RefetchBound): Promise<VerifyResult> {
202
- const coordKey = bytesToB64url(expectedCoord);
203
- const source = this.deps.router.for(tier);
204
-
205
- // Seed from the cheap cached view if we hold nothing yet. A cached cert already passed the gate when
206
- // it was loaded (or was self-published via `cache`), so it is used directly for message verification.
207
- let cert = this.byCoord.get(coordKey)?.cert;
208
- if (cert === undefined) {
209
- cert = await this.loadFrom(source.current(expectedCoord), tier);
210
- }
211
- if (cert !== undefined && this.messageVerifies(cert, signers, payload, sig)) {
212
- this.staleGapStrikes.delete(coordKey); // a verify resets the consecutive stale-gap strike count
213
- return "verified";
214
- }
215
-
216
- // Single fetch-and-retry: a stale or missing cert forces a network refresh. A flood-exposed caller
217
- // (the ungated `promote` handler) bounds the amplification via {@link RefetchBound}: the refetch is
218
- // then rate-limited to at most one per coord per interval, so a stream of verify-misses (forged
219
- // notices) cannot turn into a storm of membership dials. Eventual refetch survives — a cold cache or a
220
- // membership rotation still refreshes once the interval elapses.
221
- if (!this.refetchAllowed(coordKey, opts)) {
222
- return "untrusted";
223
- }
224
- const refreshed = await this.loadFrom(source.fetch(expectedCoord), tier);
225
- if (refreshed !== undefined && this.messageVerifies(refreshed, signers, payload, sig)) {
226
- this.staleGapStrikes.delete(coordKey); // a verify resets the consecutive stale-gap strike count
227
- return "verified";
228
- }
229
- return "untrusted";
230
- }
231
-
232
- /**
233
- * Whether a `source.fetch()` refetch is permitted for `coordKey` now. Unbounded (always `true`) unless
234
- * the caller supplies both `minRefetchIntervalMs` and `now`, in which case at most one refetch per coord
235
- * per interval is allowed — the per-coord fetch-rate bound that caps flood amplification. Records the
236
- * attempt time when it returns `true` (the dial is about to happen).
237
- */
238
- private refetchAllowed(coordKey: string, opts?: RefetchBound): boolean {
239
- const minInterval = opts?.minRefetchIntervalMs;
240
- const now = opts?.now;
241
- if (minInterval === undefined || now === undefined) {
242
- return true;
243
- }
244
- const last = this.lastFetchAt.get(coordKey);
245
- if (last !== undefined && now - last < minInterval) {
246
- return false;
247
- }
248
- this.lastFetchAt.set(coordKey, now);
249
- return true;
250
- }
251
-
252
- /**
253
- * Decode an encoded cert, run it through the trust gate, cache it (with its trusted status), and return
254
- * it for message verification; `undefined` if absent, malformed, or rejected by the gate. A rejected
255
- * cert (failed self-consistency, a `"rejected"` direct anchor, or an un-anchored cert for an
256
- * already-trusted coord) is treated exactly like an absent cert, so the single refetch still fires.
257
- */
258
- private async loadFrom(pending: Promise<Uint8Array | undefined>, tier: number): Promise<MembershipCertV1 | undefined> {
259
- const encoded = await pending;
260
- if (encoded === undefined) {
261
- return undefined;
262
- }
263
- let cert: MembershipCertV1;
264
- let trust: CertTrust;
265
- try {
266
- cert = decodeMembershipCertV1(encoded, this.deps.maxMessageBytes);
267
- trust = this.certIsTrusted(cert, tier);
268
- } catch (err) {
269
- if (err instanceof CohortWireError) {
270
- return undefined; // a malformed cert (or non-base64url signer) is treated as no cert
271
- }
272
- throw err;
273
- }
274
- if (trust === "reject") {
275
- return undefined;
276
- }
277
- // Only a `"trusted"` cert may anchor a successor in the attestation chain; a `"tofu"` cert is cached
278
- // for message verification but must never launder trust into a rotation.
279
- this.byCoord.set(cert.cohortCoord, { cert, trusted: trust === "trusted" });
280
- return cert;
281
- }
282
-
283
- /**
284
- * The trust gate (see the module header). A cert is accepted iff it is self-consistent **and** anchored
285
- * by a trust root, the direct anchor, or the attestation chain; otherwise it falls to the interim TOFU
286
- * fallback (first-use only). Returns whether the cert is a trusted anchor (`"trusted"`), an interim
287
- * TOFU acceptance (`"tofu"`), or rejected (`"reject"`).
288
- */
289
- private certIsTrusted(cert: MembershipCertV1, tier: number): CertTrust {
290
- if (!this.certIsSelfConsistent(cert)) {
291
- return "reject"; // internal well-formedness is the precondition for any trust path
292
- }
293
- if (this.matchesTrustRoot(cert)) {
294
- return "trusted"; // a configured genesis root is authoritative, checked before the direct anchor
295
- }
296
- const verdict = this.anchor.directAnchor(cert, tier);
297
- if (verdict === "anchored") {
298
- return "trusted";
299
- }
300
- if (verdict === "rejected") {
301
- return "reject"; // a contradicted binding is a forgery — fatal, overrides the TOFU fallback
302
- }
303
- // verdict === "unknown": no local authority for this coord. Try the attestation chain, else fall back.
304
- if (this.hasRotationAttestation(cert) && this.chainGrantsTrust(cert)) {
305
- return "trusted";
306
- }
307
- const fallback = this.fallbackTrust(cert);
308
- if (fallback !== "reject") {
309
- return fallback; // first-use TOFU (coord not locked): no stale lock to recover from
310
- }
311
- // fallback === "reject" ⟺ the coord is trust-locked (holds a *trusted* cert) and this un-anchored cert
312
- // did not chain-verify — the state that strands a former cohort member forever. Consult the stale-gap
313
- // recovery counter, which releases the lock only on a demonstrated chain gap (never a forged rotation
314
- // off the current predecessor). See {@link staleGapRecovery}.
315
- return this.staleGapRecovery(cert);
316
- }
317
-
318
- /**
319
- * Bounded re-TOFU recovery for a coord that is **trust-locked at a stale epoch it can no longer anchor**.
320
- * Reached from {@link certIsTrusted} only when the coord already holds a *trusted* cached cert (locked),
321
- * the direct anchor said `"unknown"`, and `cert` did not chain-verify — i.e. {@link fallbackTrust} would
322
- * otherwise reject it forever. Runs on the *refetch* load only: a locked coord always holds a cached cert,
323
- * so `verifyMessage` never routes it through the `source.current()` seed path, and the strike logic keys on
324
- * "coord is locked", so `current()` never accrues a strike.
325
- *
326
- * NOTE: recovery fires **only** on a demonstrated chain gap — a full rotation attestation whose
327
- * `prevEpoch ≠` the cached trusted epoch (proof the network rotated past the cached epoch through at least
328
- * one epoch this node never witnessed). A forged rotation off the *current* cached predecessor
329
- * (`prevEpoch == cachedEpoch`) is NOT a gap, never counts as a strike, and stays rejected no matter how
330
- * often it is presented — that is the lock's headline invariant. After
331
- * {@link staleGapRecoveryStrikes} *consecutive* gap-signalled strikes the lock is released back to TOFU
332
- * (returns `"tofu"`, so {@link loadFrom} re-caches the cert as **untrusted** — a re-TOFU'd cert must never
333
- * launder trust into a rotation), which is no weaker than the documented TOFU baseline: a former member
334
- * returns to the same regime a never-member is already in. Strikes accrue only on refetches that actually
335
- * reach the source, so a {@link RefetchBound}-suppressed refetch observes no cert and recovery paces itself
336
- * with the (bounded) refetch rate — intended, do not "fix" that pacing.
337
- */
338
- private staleGapRecovery(cert: MembershipCertV1): CertTrust {
339
- const coordKey = cert.cohortCoord;
340
- const locked = this.byCoord.get(coordKey);
341
- // Recovery-eligible only on an explicit rotation gap: a full attestation whose prevEpoch is neither the
342
- // cert's own epoch (a self-referential rotation) nor the cached trusted epoch (a forgery off the current
343
- // predecessor — the case the lock exists to reject).
344
- const isGap =
345
- this.staleGapRecoveryStrikes > 0 &&
346
- locked !== undefined &&
347
- this.hasRotationAttestation(cert) &&
348
- cert.prevEpoch !== cert.cohortEpoch &&
349
- cert.prevEpoch !== locked.cert.cohortEpoch;
350
- if (!isGap) {
351
- return "reject"; // not a recovery-eligible gap → stay locked, exactly as before
352
- }
353
- const strikes = (this.staleGapStrikes.get(coordKey) ?? 0) + 1;
354
- if (strikes < this.staleGapRecoveryStrikes) {
355
- this.staleGapStrikes.set(coordKey, strikes);
356
- return "reject"; // below threshold: keep rejecting; the inbound message stays untrusted
357
- }
358
- // Threshold reached: release the lock. `loadFrom` re-caches this cert as untrusted (`trusted: false`),
359
- // and the message-verify retry runs against it, so the inbound later-epoch message finally verifies.
360
- this.staleGapStrikes.delete(coordKey);
361
- return "tofu";
362
- }
363
-
364
- /**
365
- * The interim fallback for a coord with no trust-root / anchor / chain coverage. Trust-on-first-use:
366
- * accept a self-consistent cert when the coord holds **no trusted cert yet**, preserving the pre-anchor
367
- * behavior on coords no node can verify today. But once a coord *is* trust-established, an un-anchored
368
- * cert (a failed/absent rotation, including a forged rotation off a trusted predecessor) is **rejected**
369
- * — no silent TOFU downgrade — which is what gives the attestation chain its teeth.
370
- */
371
- private fallbackTrust(cert: MembershipCertV1): CertTrust {
372
- return this.byCoord.get(cert.cohortCoord)?.trusted ? "reject" : "tofu";
373
- }
374
-
375
- /** Whether a cert carries a full rotation attestation (all three fields; validated all-or-nothing on the wire). */
376
- private hasRotationAttestation(cert: MembershipCertV1): boolean {
377
- return cert.prevEpoch !== undefined && cert.rotationSig !== undefined && cert.rotationSigners !== undefined;
378
- }
379
-
380
- /**
381
- * Whether `cert`'s rotation attestation is valid: a **trusted** predecessor for the same coord is cached
382
- * at `prevEpoch`, the rotation is not self-referential, and the predecessor's members form a `≥ minSigs`
383
- * quorum over this cert's signing payload via `rotationSig`. A predecessor that only reached the cache via
384
- * TOFU (not trusted) must not anchor the successor — the trusted-cache invariant.
385
- */
386
- private chainGrantsTrust(cert: MembershipCertV1): boolean {
387
- const prevEpoch = cert.prevEpoch!;
388
- if (prevEpoch === cert.cohortEpoch) {
389
- return false; // a cert cannot rotate from itself
390
- }
391
- const predecessor = this.byCoord.get(cert.cohortCoord);
392
- if (predecessor === undefined || !predecessor.trusted || predecessor.cert.cohortEpoch !== prevEpoch) {
393
- return false;
394
- }
395
- // `rotationSigners` is validated only as a string array, so `b64urlToBytes` may throw `CohortWireError`
396
- // on a malformed signer — `loadFrom`'s try/catch turns that into "no cert", as for `certIsSelfConsistent`.
397
- return this.deps.signer.verifyThreshold(
398
- membershipCertSigningPayload(cert),
399
- b64urlToBytes(cert.rotationSig!),
400
- cert.rotationSigners!.map((s) => b64urlToBytes(s)),
401
- predecessor.cert,
402
- this.minSigs,
403
- );
404
- }
405
-
406
- /** Whether `cert` matches a configured trust root by `(coord, epoch)` and an order-independent member set. */
407
- private matchesTrustRoot(cert: MembershipCertV1): boolean {
408
- for (const root of this.trustRoots) {
409
- if (root.coord === cert.cohortCoord && root.epoch === cert.cohortEpoch && setEqualsArray(root.members, cert.members)) {
410
- return true;
411
- }
412
- }
413
- return false;
414
- }
415
-
416
- /** A cert is self-consistent only if its own threshold signature is a valid quorum of its members. */
417
- private certIsSelfConsistent(cert: MembershipCertV1): boolean {
418
- // `signers` is validated only as a string array (not per-element base64url), so `b64urlToBytes`
419
- // below may throw `CohortWireError` on a malformed signer — `loadFrom`'s try/catch turns that
420
- // into "no cert" rather than letting it escape.
421
- return this.deps.signer.verifyThreshold(
422
- membershipCertSigningPayload(cert),
423
- b64urlToBytes(cert.thresholdSig),
424
- cert.signers.map((s) => b64urlToBytes(s)),
425
- cert,
426
- this.minSigs,
427
- );
428
- }
429
-
430
- private messageVerifies(cert: MembershipCertV1, signers: readonly Uint8Array[], payload: Uint8Array, sig: Uint8Array): boolean {
431
- return this.deps.signer.verifyThreshold(payload, sig, signers, cert, this.minSigs);
432
- }
433
- }
434
-
435
- /** Pre-normalize a {@link TrustRoot} (raw bytes) to the cert's base64url form for cheap matching. */
436
- function normalizeTrustRoot(root: TrustRoot): NormalizedTrustRoot {
437
- return {
438
- coord: bytesToB64url(root.coord),
439
- epoch: bytesToB64url(root.epoch),
440
- members: new Set(root.members.map(bytesToB64url)),
441
- };
442
- }
443
-
444
- /** Order-independent set equality between a trust-root member set and a cert's member array (duplicate-safe). */
445
- function setEqualsArray(set: ReadonlySet<string>, members: readonly string[]): boolean {
446
- const seen = new Set<string>();
447
- for (const m of members) {
448
- if (!set.has(m)) {
449
- return false; // a member not in the root → not a root match
450
- }
451
- seen.add(m);
452
- }
453
- // Cover both directions: every distinct cert member is in the root AND every root member is covered,
454
- // so a duplicate-inflated member list cannot masquerade as a full-set match.
455
- return seen.size === set.size;
456
- }
457
-
458
- /** Build a participant-side {@link MembershipVerifier}. */
459
- export function createMembershipVerifier(deps: MembershipVerifierDeps): MembershipVerifier {
460
- return new CachingMembershipVerifier(deps);
461
- }
1
+ /**
2
+ * Cohort-topic substrate — participant-side membership verification with a trust-anchor gate.
3
+ *
4
+ * Per `docs/cohort-topic.md` §Membership snapshots and §Bootstrapping trust. A participant verifying
5
+ * a threshold-signed message:
6
+ *
7
+ * 1. takes the message's `signers`, the cohort `coord` the signers should belong to, and the tier;
8
+ * 2. looks up the cached `MembershipCertV1` for that coord (or pulls the source's `current`);
9
+ * 3. checks the signers are a `≥ minSigs` subset of the cert's members and the signature verifies;
10
+ * 4. **on failure against a cached/stale cert, re-fetches the cert from any cohort member exactly
11
+ * once and retries**; still failing → the message is untrusted.
12
+ *
13
+ * **Trust anchoring (the gate this module adds).** Self-consistency (a cert's own threshold signature
14
+ * is a `≥ minSigs` quorum over its own `members`) proves only internal well-formedness — *not* that the
15
+ * attesting key set is the legitimate cohort for the coord. An adversary controlling `k − x` keys could
16
+ * mint a self-consistent cert over a coord it does not own. So before a (re)fetched cert is believed,
17
+ * {@link CachingMembershipVerifier.certIsTrusted} requires it to be self-consistent **AND** anchored by
18
+ * at least one of:
19
+ *
20
+ * - **Trust root** — `(coord, epoch, member-set)` is in the out-of-band-seeded {@link TrustRoot} set
21
+ * (the genesis-block cohorts). Base case of every chain; checked before the direct anchor, so a
22
+ * configured root is authoritative.
23
+ * - **Direct anchor** — the injected {@link IMembershipTrustAnchor} vouches for the binding from a
24
+ * source the node directly trusts (FRET ring agreement / tx-log commit cert, bound in db-p2p). A
25
+ * `"rejected"` verdict is **fatal** (a forgery, even if self-consistent) and overrides the fallback.
26
+ * - **Attestation chain** — a cert carrying a rotation attestation (`prevEpoch`/`rotationSig`/
27
+ * `rotationSigners`) inherits trust when the node already holds a **trusted** predecessor for the same
28
+ * coord at `prevEpoch` whose members form a `≥ minSigs` quorum over this cert's signing payload.
29
+ *
30
+ * **Interim TOFU fallback (documented limit).** For a coord the node cannot anchor (the direct anchor
31
+ * returns `"unknown"` and there is no trust root / chain) and that has **no trusted cert yet**, the
32
+ * verifier falls back to trust-on-first-use of any self-consistent cert — identical to the pre-anchor
33
+ * behavior, so there is strictly no regression on coords no node can verify today (distant T2/T3, and
34
+ * T0/T1 until the committed-index binding lands). Once a coord *does* hold a trusted cert, the chain
35
+ * governs successors: an un-anchored cert for an already-trusted coord is rejected (no TOFU downgrade),
36
+ * which is what gives the rotation chain its teeth. The FRET / tx-log direct-anchor bindings that close
37
+ * the remaining TOFU gap are tracked in `cohort-topic-trust-anchor-fret-binding` and the backlog
38
+ * `...-fret-stabilization-proof` / `...-txlog-committed-binding` tickets.
39
+ *
40
+ * **Deviation from the ticket sketch (documented):** `verifyMessage` takes the cohort `tier`. A coord
41
+ * is an opaque hash, so the T0/T1-vs-T2/T3 source dispatch the same ticket mandates cannot be derived
42
+ * from the coord alone; the caller already knows the tier (it computed the coord from the message's
43
+ * claimed tier/topic). The `tier` is threaded into the gate so the direct anchor is consulted with the
44
+ * same tier the router used (the binding is tier-scoped).
45
+ */
46
+
47
+ import type { IMembershipTrustAnchor, RingCoord, TrustRoot } from "../ports.js";
48
+ import { noAuthorityTrustAnchor } from "../ports.js";
49
+ import { b64urlToBytes, bytesToB64url, decodeMembershipCertV1 } from "../wire/codec.js";
50
+ import { CohortWireError } from "../wire/validate.js";
51
+ import type { MembershipCertV1 } from "../wire/types.js";
52
+ import { DEFAULT_MIN_SIGS, type CohortSigner } from "../sig/threshold.js";
53
+ import { membershipCertSigningPayload } from "../sig/payloads.js";
54
+ import type { IMembershipSourceRouter } from "./source.js";
55
+ import { LruMap } from "../../utility/lru-map.js";
56
+
57
+ /**
58
+ * Default hard cap on distinct coords the verifier's per-coord maps retain (`byCoord`, `lastFetchAt`,
59
+ * `staleGapStrikes`). Beyond it the least-recently-used coord is evicted — bounding memory under a flood
60
+ * of verify-misses against attacker-chosen coords. Same 100k ballpark as the sibling anti-DoS caps
61
+ * ({@link import("../antidos/replay-guard.js").DEFAULT_REPLAY_GUARD_MAX_KEYS}).
62
+ */
63
+ export const DEFAULT_MEMBERSHIP_VERIFIER_MAX_COORDS = 100_000;
64
+
65
+ /** Outcome of verifying a threshold-signed message against cohort membership. */
66
+ export type VerifyResult = "verified" | "untrusted";
67
+
68
+ /** Caches certs per coord and verifies threshold-signed messages with one stale-cert refetch. */
69
+ export interface MembershipVerifier {
70
+ /** Cache `cert` as the latest known membership for its coord. */
71
+ cache(cert: MembershipCertV1): void;
72
+ /**
73
+ * Verify a threshold-signed message. `expectedCoord` is the cohort the `signers` should belong to;
74
+ * `tier` selects the membership source. Performs the single refetch+retry internally.
75
+ *
76
+ * `opts` bounds the network amplification a flood-exposed caller (the `promote` handler) can suffer:
77
+ * when **both** `minRefetchIntervalMs` and `now` are given, the stale-cert `source.fetch()` retry is
78
+ * **rate-limited per coord** — at most one refetch per coord per interval — so a stream of verify-misses
79
+ * (e.g. forged notices) drives a bounded membership-fetch rate rather than one dial per message.
80
+ * Eventual refetch is *preserved*: a cold cache or a membership rotation still re-fetches once the
81
+ * interval elapses (unlike outright suppression). Omit `opts` (the default, and every existing caller)
82
+ * for the unbounded exactly-one-refetch behavior.
83
+ */
84
+ verifyMessage(signers: readonly Uint8Array[], expectedCoord: RingCoord, tier: number, payload: Uint8Array, sig: Uint8Array, opts?: RefetchBound): Promise<VerifyResult>;
85
+ }
86
+
87
+ /**
88
+ * Caller-supplied bound on the membership-cert refetch rate (anti-amplification on a flood-exposed verify
89
+ * path). Both fields are required to take effect; omit either for unbounded refetch.
90
+ */
91
+ export interface RefetchBound {
92
+ /** Minimum wall-clock gap (ms) between `source.fetch()` refetches for the same coord. */
93
+ readonly minRefetchIntervalMs?: number;
94
+ /** Current wall clock (ms) for the interval comparison. */
95
+ readonly now?: number;
96
+ }
97
+
98
+ export interface MembershipVerifierDeps {
99
+ signer: CohortSigner;
100
+ router: IMembershipSourceRouter;
101
+ minSigs?: number;
102
+ maxMessageBytes?: number;
103
+ /**
104
+ * Direct (base-case) trust anchor for a cert's `coord → keyset` binding. Defaults to
105
+ * {@link noAuthorityTrustAnchor} (every coord `"unknown"`), which preserves the interim TOFU behavior.
106
+ * db-p2p injects the FRET-ring-backed anchor.
107
+ */
108
+ anchor?: IMembershipTrustAnchor;
109
+ /** Out-of-band-seeded genesis trust roots (the base case of every attestation chain). Defaults to `[]`. */
110
+ trustRoots?: readonly TrustRoot[];
111
+ /**
112
+ * Number of **consecutive** gap-signalled refetches after which a **trust-locked** coord whose direct
113
+ * anchor has gone `"unknown"` re-enters the interim TOFU regime — the exit from a stale trust-lock a
114
+ * former cohort member would otherwise be stranded in until the host process restarts.
115
+ *
116
+ * The lock (a coord holding a *trusted* cached cert refuses any un-anchored refetch — no TOFU downgrade)
117
+ * has no other exit: a node that served coord `C`, self-published its cert (locking `C`), then left `C`'s
118
+ * cohort keeps distrusting every later-epoch message from `C` if it missed an intermediate rotation, since
119
+ * the refetched cert's `prevEpoch` no longer matches the stale cached epoch and the anchor no longer
120
+ * vouches for `C`. Recovery counts **only** refetched certs presenting an *explicit chain gap* — a full
121
+ * rotation attestation whose `prevEpoch ≠` the cached trusted epoch (the network provably rotated past the
122
+ * cached epoch through an epoch this node never witnessed). A forged rotation off the *current* cached
123
+ * predecessor (`prevEpoch == cachedEpoch`) never counts as a strike, so the lock's headline invariant
124
+ * (un-anchored successor of a matching predecessor stays rejected) is preserved.
125
+ *
126
+ * Defaults to `3`. Setting it to `0` (or a negative) **disables** recovery — which re-opens the stale-lock
127
+ * liveness bug, so leave it on unless a caller has an independent lock-drop mechanism (see the
128
+ * drop-the-lock-on-demotion tripwire in the ticket).
129
+ */
130
+ staleGapRecoveryStrikes?: number;
131
+ /**
132
+ * Hard LRU cap on distinct coords retained across the verifier's per-coord maps (`byCoord`,
133
+ * `lastFetchAt`, `staleGapStrikes`); the least-recently-used coord is evicted beyond it. Bounds memory
134
+ * under a flood of verify-misses against attacker-chosen coords. Defaults to
135
+ * {@link DEFAULT_MEMBERSHIP_VERIFIER_MAX_COORDS}; must be a positive integer (mirrors the sibling
136
+ * anti-DoS `maxKeys` guard).
137
+ */
138
+ maxCoords?: number;
139
+ }
140
+
141
+ /** Result of the trust gate: accept as a trusted anchor, accept as interim TOFU, or reject outright. */
142
+ type CertTrust = "trusted" | "tofu" | "reject";
143
+
144
+ /** A cached cert and whether it is *trusted* (may serve as an attestation-chain predecessor). */
145
+ interface CachedCert {
146
+ cert: MembershipCertV1;
147
+ /** True only when the cert passed via trust-root / direct-anchor / chain, or was self-published (`cache`). */
148
+ trusted: boolean;
149
+ }
150
+
151
+ /** A {@link TrustRoot} pre-normalized to the cert's base64url form for cheap matching. */
152
+ interface NormalizedTrustRoot {
153
+ coord: string;
154
+ epoch: string;
155
+ members: ReadonlySet<string>;
156
+ }
157
+
158
+ class CachingMembershipVerifier implements MembershipVerifier {
159
+ // LRU-capped so a flood of verify-misses against attacker-chosen coords cannot grow these maps without
160
+ // bound (coordKey is base64url of an attacker-derivable RingCoord). Each is capped INDEPENDENTLY: the
161
+ // aux maps may briefly retain a coord `byCoord` already evicted — harmless (a stale `lastFetchAt` at
162
+ // worst permits one extra refetch; a stale strike count is itself bounded).
163
+ // NOTE: `byCoord` also holds this node's OWN `cache()`-published trusted cert (the trust lock). Under an
164
+ // attacker-coord flood the LRU can evict a trusted self-published entry, re-opening that coord to TOFU on
165
+ // next sight — the trust lock is best-effort under memory pressure. This mirrors the documented penalty
166
+ // tradeoff on the replay-guard cap and is acceptable; do not file it as a separate ticket.
167
+ private readonly byCoord: LruMap<string, CachedCert>;
168
+ /** Per-coord timestamp of the last `source.fetch()` attempt (the rate-limit clock for {@link RefetchBound}). */
169
+ private readonly lastFetchAt: LruMap<string, number>;
170
+ /**
171
+ * Per-coord count of *consecutive* gap-signalled refetches against a trust-locked coord (base64url key).
172
+ * Reset to zero whenever a message verifies for the coord (see {@link verifyMessage}); at
173
+ * {@link staleGapRecoveryStrikes} the lock is released back to TOFU (see {@link staleGapRecovery}).
174
+ */
175
+ private readonly staleGapStrikes: LruMap<string, number>;
176
+ private readonly minSigs: number;
177
+ private readonly anchor: IMembershipTrustAnchor;
178
+ private readonly trustRoots: readonly NormalizedTrustRoot[];
179
+ private readonly staleGapRecoveryStrikes: number;
180
+
181
+ constructor(private readonly deps: MembershipVerifierDeps) {
182
+ this.minSigs = deps.minSigs ?? DEFAULT_MIN_SIGS;
183
+ this.anchor = deps.anchor ?? noAuthorityTrustAnchor;
184
+ this.trustRoots = (deps.trustRoots ?? []).map(normalizeTrustRoot);
185
+ this.staleGapRecoveryStrikes = deps.staleGapRecoveryStrikes ?? 3;
186
+ const maxCoords = deps.maxCoords ?? DEFAULT_MEMBERSHIP_VERIFIER_MAX_COORDS;
187
+ if (!Number.isInteger(maxCoords) || maxCoords <= 0) {
188
+ throw new RangeError(`maxCoords must be a positive integer, got ${maxCoords}`);
189
+ }
190
+ this.byCoord = new LruMap(maxCoords);
191
+ this.lastFetchAt = new LruMap(maxCoords);
192
+ this.staleGapStrikes = new LruMap(maxCoords);
193
+ }
194
+
195
+ cache(cert: MembershipCertV1): void {
196
+ // The public cache feeds this node its OWN freshly-published cert — a node trusts a cert it itself
197
+ // published, so it is marked trusted and may anchor the next rotation in the attestation chain.
198
+ this.byCoord.set(cert.cohortCoord, { cert, trusted: true });
199
+ }
200
+
201
+ async verifyMessage(signers: readonly Uint8Array[], expectedCoord: RingCoord, tier: number, payload: Uint8Array, sig: Uint8Array, opts?: RefetchBound): Promise<VerifyResult> {
202
+ const coordKey = bytesToB64url(expectedCoord);
203
+ const source = this.deps.router.for(tier);
204
+
205
+ // Seed from the cheap cached view if we hold nothing yet. A cached cert already passed the gate when
206
+ // it was loaded (or was self-published via `cache`), so it is used directly for message verification.
207
+ let cert = this.byCoord.get(coordKey)?.cert;
208
+ if (cert === undefined) {
209
+ cert = await this.loadFrom(source.current(expectedCoord), tier);
210
+ }
211
+ if (cert !== undefined && this.messageVerifies(cert, signers, payload, sig)) {
212
+ this.staleGapStrikes.delete(coordKey); // a verify resets the consecutive stale-gap strike count
213
+ return "verified";
214
+ }
215
+
216
+ // Single fetch-and-retry: a stale or missing cert forces a network refresh. A flood-exposed caller
217
+ // (the ungated `promote` handler) bounds the amplification via {@link RefetchBound}: the refetch is
218
+ // then rate-limited to at most one per coord per interval, so a stream of verify-misses (forged
219
+ // notices) cannot turn into a storm of membership dials. Eventual refetch survives — a cold cache or a
220
+ // membership rotation still refreshes once the interval elapses.
221
+ if (!this.refetchAllowed(coordKey, opts)) {
222
+ return "untrusted";
223
+ }
224
+ const refreshed = await this.loadFrom(source.fetch(expectedCoord), tier);
225
+ if (refreshed !== undefined && this.messageVerifies(refreshed, signers, payload, sig)) {
226
+ this.staleGapStrikes.delete(coordKey); // a verify resets the consecutive stale-gap strike count
227
+ return "verified";
228
+ }
229
+ return "untrusted";
230
+ }
231
+
232
+ /**
233
+ * Whether a `source.fetch()` refetch is permitted for `coordKey` now. Unbounded (always `true`) unless
234
+ * the caller supplies both `minRefetchIntervalMs` and `now`, in which case at most one refetch per coord
235
+ * per interval is allowed — the per-coord fetch-rate bound that caps flood amplification. Records the
236
+ * attempt time when it returns `true` (the dial is about to happen).
237
+ */
238
+ private refetchAllowed(coordKey: string, opts?: RefetchBound): boolean {
239
+ const minInterval = opts?.minRefetchIntervalMs;
240
+ const now = opts?.now;
241
+ if (minInterval === undefined || now === undefined) {
242
+ return true;
243
+ }
244
+ const last = this.lastFetchAt.get(coordKey);
245
+ if (last !== undefined && now - last < minInterval) {
246
+ return false;
247
+ }
248
+ this.lastFetchAt.set(coordKey, now);
249
+ return true;
250
+ }
251
+
252
+ /**
253
+ * Decode an encoded cert, run it through the trust gate, cache it (with its trusted status), and return
254
+ * it for message verification; `undefined` if absent, malformed, or rejected by the gate. A rejected
255
+ * cert (failed self-consistency, a `"rejected"` direct anchor, or an un-anchored cert for an
256
+ * already-trusted coord) is treated exactly like an absent cert, so the single refetch still fires.
257
+ */
258
+ private async loadFrom(pending: Promise<Uint8Array | undefined>, tier: number): Promise<MembershipCertV1 | undefined> {
259
+ const encoded = await pending;
260
+ if (encoded === undefined) {
261
+ return undefined;
262
+ }
263
+ let cert: MembershipCertV1;
264
+ let trust: CertTrust;
265
+ try {
266
+ cert = decodeMembershipCertV1(encoded, this.deps.maxMessageBytes);
267
+ trust = this.certIsTrusted(cert, tier);
268
+ } catch (err) {
269
+ if (err instanceof CohortWireError) {
270
+ return undefined; // a malformed cert (or non-base64url signer) is treated as no cert
271
+ }
272
+ throw err;
273
+ }
274
+ if (trust === "reject") {
275
+ return undefined;
276
+ }
277
+ // Only a `"trusted"` cert may anchor a successor in the attestation chain; a `"tofu"` cert is cached
278
+ // for message verification but must never launder trust into a rotation.
279
+ this.byCoord.set(cert.cohortCoord, { cert, trusted: trust === "trusted" });
280
+ return cert;
281
+ }
282
+
283
+ /**
284
+ * The trust gate (see the module header). A cert is accepted iff it is self-consistent **and** anchored
285
+ * by a trust root, the direct anchor, or the attestation chain; otherwise it falls to the interim TOFU
286
+ * fallback (first-use only). Returns whether the cert is a trusted anchor (`"trusted"`), an interim
287
+ * TOFU acceptance (`"tofu"`), or rejected (`"reject"`).
288
+ */
289
+ private certIsTrusted(cert: MembershipCertV1, tier: number): CertTrust {
290
+ if (!this.certIsSelfConsistent(cert)) {
291
+ return "reject"; // internal well-formedness is the precondition for any trust path
292
+ }
293
+ if (this.matchesTrustRoot(cert)) {
294
+ return "trusted"; // a configured genesis root is authoritative, checked before the direct anchor
295
+ }
296
+ const verdict = this.anchor.directAnchor(cert, tier);
297
+ if (verdict === "anchored") {
298
+ return "trusted";
299
+ }
300
+ if (verdict === "rejected") {
301
+ return "reject"; // a contradicted binding is a forgery — fatal, overrides the TOFU fallback
302
+ }
303
+ // verdict === "unknown": no local authority for this coord. Try the attestation chain, else fall back.
304
+ if (this.hasRotationAttestation(cert) && this.chainGrantsTrust(cert)) {
305
+ return "trusted";
306
+ }
307
+ const fallback = this.fallbackTrust(cert);
308
+ if (fallback !== "reject") {
309
+ return fallback; // first-use TOFU (coord not locked): no stale lock to recover from
310
+ }
311
+ // fallback === "reject" ⟺ the coord is trust-locked (holds a *trusted* cert) and this un-anchored cert
312
+ // did not chain-verify — the state that strands a former cohort member forever. Consult the stale-gap
313
+ // recovery counter, which releases the lock only on a demonstrated chain gap (never a forged rotation
314
+ // off the current predecessor). See {@link staleGapRecovery}.
315
+ return this.staleGapRecovery(cert);
316
+ }
317
+
318
+ /**
319
+ * Bounded re-TOFU recovery for a coord that is **trust-locked at a stale epoch it can no longer anchor**.
320
+ * Reached from {@link certIsTrusted} only when the coord already holds a *trusted* cached cert (locked),
321
+ * the direct anchor said `"unknown"`, and `cert` did not chain-verify — i.e. {@link fallbackTrust} would
322
+ * otherwise reject it forever. Runs on the *refetch* load only: a locked coord always holds a cached cert,
323
+ * so `verifyMessage` never routes it through the `source.current()` seed path, and the strike logic keys on
324
+ * "coord is locked", so `current()` never accrues a strike.
325
+ *
326
+ * NOTE: recovery fires **only** on a demonstrated chain gap — a full rotation attestation whose
327
+ * `prevEpoch ≠` the cached trusted epoch (proof the network rotated past the cached epoch through at least
328
+ * one epoch this node never witnessed). A forged rotation off the *current* cached predecessor
329
+ * (`prevEpoch == cachedEpoch`) is NOT a gap, never counts as a strike, and stays rejected no matter how
330
+ * often it is presented — that is the lock's headline invariant. After
331
+ * {@link staleGapRecoveryStrikes} *consecutive* gap-signalled strikes the lock is released back to TOFU
332
+ * (returns `"tofu"`, so {@link loadFrom} re-caches the cert as **untrusted** — a re-TOFU'd cert must never
333
+ * launder trust into a rotation), which is no weaker than the documented TOFU baseline: a former member
334
+ * returns to the same regime a never-member is already in. Strikes accrue only on refetches that actually
335
+ * reach the source, so a {@link RefetchBound}-suppressed refetch observes no cert and recovery paces itself
336
+ * with the (bounded) refetch rate — intended, do not "fix" that pacing.
337
+ */
338
+ private staleGapRecovery(cert: MembershipCertV1): CertTrust {
339
+ const coordKey = cert.cohortCoord;
340
+ const locked = this.byCoord.get(coordKey);
341
+ // Recovery-eligible only on an explicit rotation gap: a full attestation whose prevEpoch is neither the
342
+ // cert's own epoch (a self-referential rotation) nor the cached trusted epoch (a forgery off the current
343
+ // predecessor — the case the lock exists to reject).
344
+ const isGap =
345
+ this.staleGapRecoveryStrikes > 0 &&
346
+ locked !== undefined &&
347
+ this.hasRotationAttestation(cert) &&
348
+ cert.prevEpoch !== cert.cohortEpoch &&
349
+ cert.prevEpoch !== locked.cert.cohortEpoch;
350
+ if (!isGap) {
351
+ return "reject"; // not a recovery-eligible gap → stay locked, exactly as before
352
+ }
353
+ const strikes = (this.staleGapStrikes.get(coordKey) ?? 0) + 1;
354
+ if (strikes < this.staleGapRecoveryStrikes) {
355
+ this.staleGapStrikes.set(coordKey, strikes);
356
+ return "reject"; // below threshold: keep rejecting; the inbound message stays untrusted
357
+ }
358
+ // Threshold reached: release the lock. `loadFrom` re-caches this cert as untrusted (`trusted: false`),
359
+ // and the message-verify retry runs against it, so the inbound later-epoch message finally verifies.
360
+ this.staleGapStrikes.delete(coordKey);
361
+ return "tofu";
362
+ }
363
+
364
+ /**
365
+ * The interim fallback for a coord with no trust-root / anchor / chain coverage. Trust-on-first-use:
366
+ * accept a self-consistent cert when the coord holds **no trusted cert yet**, preserving the pre-anchor
367
+ * behavior on coords no node can verify today. But once a coord *is* trust-established, an un-anchored
368
+ * cert (a failed/absent rotation, including a forged rotation off a trusted predecessor) is **rejected**
369
+ * — no silent TOFU downgrade — which is what gives the attestation chain its teeth.
370
+ */
371
+ private fallbackTrust(cert: MembershipCertV1): CertTrust {
372
+ return this.byCoord.get(cert.cohortCoord)?.trusted ? "reject" : "tofu";
373
+ }
374
+
375
+ /** Whether a cert carries a full rotation attestation (all three fields; validated all-or-nothing on the wire). */
376
+ private hasRotationAttestation(cert: MembershipCertV1): boolean {
377
+ return cert.prevEpoch !== undefined && cert.rotationSig !== undefined && cert.rotationSigners !== undefined;
378
+ }
379
+
380
+ /**
381
+ * Whether `cert`'s rotation attestation is valid: a **trusted** predecessor for the same coord is cached
382
+ * at `prevEpoch`, the rotation is not self-referential, and the predecessor's members form a `≥ minSigs`
383
+ * quorum over this cert's signing payload via `rotationSig`. A predecessor that only reached the cache via
384
+ * TOFU (not trusted) must not anchor the successor — the trusted-cache invariant.
385
+ */
386
+ private chainGrantsTrust(cert: MembershipCertV1): boolean {
387
+ const prevEpoch = cert.prevEpoch!;
388
+ if (prevEpoch === cert.cohortEpoch) {
389
+ return false; // a cert cannot rotate from itself
390
+ }
391
+ const predecessor = this.byCoord.get(cert.cohortCoord);
392
+ if (predecessor === undefined || !predecessor.trusted || predecessor.cert.cohortEpoch !== prevEpoch) {
393
+ return false;
394
+ }
395
+ // `rotationSigners` is validated only as a string array, so `b64urlToBytes` may throw `CohortWireError`
396
+ // on a malformed signer — `loadFrom`'s try/catch turns that into "no cert", as for `certIsSelfConsistent`.
397
+ return this.deps.signer.verifyThreshold(
398
+ membershipCertSigningPayload(cert),
399
+ b64urlToBytes(cert.rotationSig!),
400
+ cert.rotationSigners!.map((s) => b64urlToBytes(s)),
401
+ predecessor.cert,
402
+ this.minSigs,
403
+ );
404
+ }
405
+
406
+ /** Whether `cert` matches a configured trust root by `(coord, epoch)` and an order-independent member set. */
407
+ private matchesTrustRoot(cert: MembershipCertV1): boolean {
408
+ for (const root of this.trustRoots) {
409
+ if (root.coord === cert.cohortCoord && root.epoch === cert.cohortEpoch && setEqualsArray(root.members, cert.members)) {
410
+ return true;
411
+ }
412
+ }
413
+ return false;
414
+ }
415
+
416
+ /** A cert is self-consistent only if its own threshold signature is a valid quorum of its members. */
417
+ private certIsSelfConsistent(cert: MembershipCertV1): boolean {
418
+ // `signers` is validated only as a string array (not per-element base64url), so `b64urlToBytes`
419
+ // below may throw `CohortWireError` on a malformed signer — `loadFrom`'s try/catch turns that
420
+ // into "no cert" rather than letting it escape.
421
+ return this.deps.signer.verifyThreshold(
422
+ membershipCertSigningPayload(cert),
423
+ b64urlToBytes(cert.thresholdSig),
424
+ cert.signers.map((s) => b64urlToBytes(s)),
425
+ cert,
426
+ this.minSigs,
427
+ );
428
+ }
429
+
430
+ private messageVerifies(cert: MembershipCertV1, signers: readonly Uint8Array[], payload: Uint8Array, sig: Uint8Array): boolean {
431
+ return this.deps.signer.verifyThreshold(payload, sig, signers, cert, this.minSigs);
432
+ }
433
+ }
434
+
435
+ /** Pre-normalize a {@link TrustRoot} (raw bytes) to the cert's base64url form for cheap matching. */
436
+ function normalizeTrustRoot(root: TrustRoot): NormalizedTrustRoot {
437
+ return {
438
+ coord: bytesToB64url(root.coord),
439
+ epoch: bytesToB64url(root.epoch),
440
+ members: new Set(root.members.map(bytesToB64url)),
441
+ };
442
+ }
443
+
444
+ /** Order-independent set equality between a trust-root member set and a cert's member array (duplicate-safe). */
445
+ function setEqualsArray(set: ReadonlySet<string>, members: readonly string[]): boolean {
446
+ const seen = new Set<string>();
447
+ for (const m of members) {
448
+ if (!set.has(m)) {
449
+ return false; // a member not in the root → not a root match
450
+ }
451
+ seen.add(m);
452
+ }
453
+ // Cover both directions: every distinct cert member is in the root AND every root member is covered,
454
+ // so a duplicate-inflated member list cannot masquerade as a full-set match.
455
+ return seen.size === set.size;
456
+ }
457
+
458
+ /** Build a participant-side {@link MembershipVerifier}. */
459
+ export function createMembershipVerifier(deps: MembershipVerifierDeps): MembershipVerifier {
460
+ return new CachingMembershipVerifier(deps);
461
+ }