@optimystic/db-core 0.14.0 → 0.16.2
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/dist/src/blocks/block-store.d.ts +18 -1
- package/dist/src/blocks/block-store.d.ts.map +1 -1
- package/dist/src/blocks/helpers.d.ts +2 -2
- package/dist/src/blocks/helpers.d.ts.map +1 -1
- package/dist/src/blocks/helpers.js +2 -2
- package/dist/src/blocks/helpers.js.map +1 -1
- package/dist/src/btree/btree.d.ts +27 -6
- package/dist/src/btree/btree.d.ts.map +1 -1
- package/dist/src/btree/btree.js +104 -61
- package/dist/src/btree/btree.js.map +1 -1
- package/dist/src/chain/chain.d.ts.map +1 -1
- package/dist/src/chain/chain.js +11 -1
- package/dist/src/chain/chain.js.map +1 -1
- package/dist/src/cluster/index.d.ts +1 -0
- package/dist/src/cluster/index.d.ts.map +1 -1
- package/dist/src/cluster/index.js +1 -0
- package/dist/src/cluster/index.js.map +1 -1
- package/dist/src/cluster/membership.d.ts +49 -0
- package/dist/src/cluster/membership.d.ts.map +1 -0
- package/dist/src/cluster/membership.js +75 -0
- package/dist/src/cluster/membership.js.map +1 -0
- package/dist/src/cluster/structs.d.ts +50 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +9 -1
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/cohort-topic/addressing.d.ts +8 -2
- package/dist/src/cohort-topic/addressing.d.ts.map +1 -1
- package/dist/src/cohort-topic/addressing.js +11 -3
- package/dist/src/cohort-topic/addressing.js.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts +13 -7
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.js +17 -6
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.js.map +1 -1
- package/dist/src/cohort-topic/antidos/replay-guard.d.ts +17 -0
- package/dist/src/cohort-topic/antidos/replay-guard.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/replay-guard.js +33 -0
- package/dist/src/cohort-topic/antidos/replay-guard.js.map +1 -1
- package/dist/src/cohort-topic/antidos/topic-budget.d.ts +7 -0
- package/dist/src/cohort-topic/antidos/topic-budget.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/topic-budget.js +8 -2
- package/dist/src/cohort-topic/antidos/topic-budget.js.map +1 -1
- package/dist/src/cohort-topic/coldstart.d.ts +34 -14
- package/dist/src/cohort-topic/coldstart.d.ts.map +1 -1
- package/dist/src/cohort-topic/coldstart.js +25 -11
- package/dist/src/cohort-topic/coldstart.js.map +1 -1
- package/dist/src/cohort-topic/gossip/bus.d.ts +12 -1
- package/dist/src/cohort-topic/gossip/bus.d.ts.map +1 -1
- package/dist/src/cohort-topic/gossip/bus.js +27 -1
- package/dist/src/cohort-topic/gossip/bus.js.map +1 -1
- package/dist/src/cohort-topic/gossip/view.d.ts +8 -0
- package/dist/src/cohort-topic/gossip/view.d.ts.map +1 -1
- package/dist/src/cohort-topic/gossip/view.js +17 -1
- package/dist/src/cohort-topic/gossip/view.js.map +1 -1
- package/dist/src/cohort-topic/member-engine.d.ts +10 -3
- package/dist/src/cohort-topic/member-engine.d.ts.map +1 -1
- package/dist/src/cohort-topic/member-engine.js +29 -14
- package/dist/src/cohort-topic/member-engine.js.map +1 -1
- package/dist/src/cohort-topic/membership/publisher.d.ts +14 -7
- package/dist/src/cohort-topic/membership/publisher.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/publisher.js +15 -27
- package/dist/src/cohort-topic/membership/publisher.js.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.d.ts +35 -0
- package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.js +88 -3
- package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
- package/dist/src/cohort-topic/promotion.d.ts +7 -0
- package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
- package/dist/src/cohort-topic/promotion.js +16 -2
- package/dist/src/cohort-topic/promotion.js.map +1 -1
- package/dist/src/cohort-topic/registration/renewal.d.ts +18 -0
- package/dist/src/cohort-topic/registration/renewal.d.ts.map +1 -1
- package/dist/src/cohort-topic/registration/renewal.js +54 -0
- package/dist/src/cohort-topic/registration/renewal.js.map +1 -1
- package/dist/src/cohort-topic/registration/types.d.ts +12 -0
- package/dist/src/cohort-topic/registration/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/registration/types.js +14 -0
- package/dist/src/cohort-topic/registration/types.js.map +1 -1
- package/dist/src/cohort-topic/service.d.ts +4 -3
- package/dist/src/cohort-topic/service.d.ts.map +1 -1
- package/dist/src/cohort-topic/service.js +38 -12
- package/dist/src/cohort-topic/service.js.map +1 -1
- package/dist/src/cohort-topic/sig/payloads.d.ts +23 -7
- package/dist/src/cohort-topic/sig/payloads.d.ts.map +1 -1
- package/dist/src/cohort-topic/sig/payloads.js +22 -6
- package/dist/src/cohort-topic/sig/payloads.js.map +1 -1
- package/dist/src/cohort-topic/traffic.d.ts +6 -0
- package/dist/src/cohort-topic/traffic.d.ts.map +1 -1
- package/dist/src/cohort-topic/traffic.js +16 -1
- package/dist/src/cohort-topic/traffic.js.map +1 -1
- package/dist/src/cohort-topic/walk.d.ts +22 -7
- package/dist/src/cohort-topic/walk.d.ts.map +1 -1
- package/dist/src/cohort-topic/walk.js +78 -18
- package/dist/src/cohort-topic/walk.js.map +1 -1
- package/dist/src/cohort-topic/wire/codec.d.ts +3 -1
- package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/codec.js +7 -1
- package/dist/src/cohort-topic/wire/codec.js.map +1 -1
- package/dist/src/cohort-topic/wire/index.d.ts +1 -1
- package/dist/src/cohort-topic/wire/index.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/index.js +1 -1
- package/dist/src/cohort-topic/wire/index.js.map +1 -1
- package/dist/src/cohort-topic/wire/payloads.d.ts +5 -4
- package/dist/src/cohort-topic/wire/payloads.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/payloads.js +14 -5
- package/dist/src/cohort-topic/wire/payloads.js.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.d.ts +65 -0
- package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -0
- package/dist/src/cohort-topic/wire/primitives.js +173 -0
- package/dist/src/cohort-topic/wire/primitives.js.map +1 -0
- package/dist/src/cohort-topic/wire/types.d.ts +132 -5
- package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.d.ts +15 -5
- package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.js +146 -123
- package/dist/src/cohort-topic/wire/validate.js.map +1 -1
- package/dist/src/collection/collection.d.ts +23 -12
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +111 -35
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +37 -2
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +16 -1
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +18 -1
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +36 -6
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/log/log.d.ts +26 -3
- package/dist/src/log/log.d.ts.map +1 -1
- package/dist/src/log/log.js +34 -7
- package/dist/src/log/log.js.map +1 -1
- package/dist/src/log/struct.d.ts +5 -4
- package/dist/src/log/struct.d.ts.map +1 -1
- package/dist/src/log/struct.js.map +1 -1
- package/dist/src/matchmaking/wire.d.ts +2 -1
- package/dist/src/matchmaking/wire.d.ts.map +1 -1
- package/dist/src/matchmaking/wire.js +13 -92
- package/dist/src/matchmaking/wire.js.map +1 -1
- package/dist/src/network/i-key-network.d.ts +9 -0
- package/dist/src/network/i-key-network.d.ts.map +1 -1
- package/dist/src/network/i-repo.d.ts +8 -0
- package/dist/src/network/i-repo.d.ts.map +1 -1
- package/dist/src/network/struct.d.ts +10 -0
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/reactivity/push-state.d.ts.map +1 -1
- package/dist/src/reactivity/push-state.js +4 -33
- package/dist/src/reactivity/push-state.js.map +1 -1
- package/dist/src/reactivity/wire-validate.d.ts +7 -22
- package/dist/src/reactivity/wire-validate.d.ts.map +1 -1
- package/dist/src/reactivity/wire-validate.js +7 -73
- package/dist/src/reactivity/wire-validate.js.map +1 -1
- package/dist/src/reactivity/wire.js +3 -3
- package/dist/src/reactivity/wire.js.map +1 -1
- package/dist/src/testing/async-wait.d.ts +23 -0
- package/dist/src/testing/async-wait.d.ts.map +1 -0
- package/dist/src/testing/async-wait.js +42 -0
- package/dist/src/testing/async-wait.js.map +1 -0
- package/dist/src/testing/index.d.ts +3 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +3 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/src/testing/test-transactor.d.ts +44 -0
- package/dist/src/testing/test-transactor.d.ts.map +1 -0
- package/dist/src/testing/test-transactor.js +423 -0
- package/dist/src/testing/test-transactor.js.map +1 -0
- package/dist/src/transaction/actions-engine.d.ts +6 -3
- package/dist/src/transaction/actions-engine.d.ts.map +1 -1
- package/dist/src/transaction/actions-engine.js +8 -7
- package/dist/src/transaction/actions-engine.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +43 -22
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +443 -178
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/errors.d.ts +82 -0
- package/dist/src/transaction/errors.d.ts.map +1 -0
- package/dist/src/transaction/errors.js +93 -0
- package/dist/src/transaction/errors.js.map +1 -0
- package/dist/src/transaction/index.d.ts +6 -3
- package/dist/src/transaction/index.d.ts.map +1 -1
- package/dist/src/transaction/index.js +4 -2
- package/dist/src/transaction/index.js.map +1 -1
- package/dist/src/transaction/operations-hash.d.ts +119 -0
- package/dist/src/transaction/operations-hash.d.ts.map +1 -0
- package/dist/src/transaction/operations-hash.js +180 -0
- package/dist/src/transaction/operations-hash.js.map +1 -0
- package/dist/src/transaction/read-dependency-collector.d.ts +43 -0
- package/dist/src/transaction/read-dependency-collector.d.ts.map +1 -0
- package/dist/src/transaction/read-dependency-collector.js +69 -0
- package/dist/src/transaction/read-dependency-collector.js.map +1 -0
- package/dist/src/transaction/session.d.ts +12 -3
- package/dist/src/transaction/session.d.ts.map +1 -1
- package/dist/src/transaction/session.js +35 -9
- package/dist/src/transaction/session.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +162 -1
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transaction/transaction.js +85 -3
- package/dist/src/transaction/transaction.js.map +1 -1
- package/dist/src/transaction/validator.d.ts +3 -11
- package/dist/src/transaction/validator.d.ts.map +1 -1
- package/dist/src/transaction/validator.js +69 -28
- package/dist/src/transaction/validator.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +35 -0
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +146 -40
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +19 -5
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +43 -11
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/atomic-proxy.d.ts +35 -5
- package/dist/src/transform/atomic-proxy.d.ts.map +1 -1
- package/dist/src/transform/atomic-proxy.js +39 -7
- package/dist/src/transform/atomic-proxy.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +36 -5
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +84 -8
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/helpers.d.ts.map +1 -1
- package/dist/src/transform/helpers.js +8 -2
- package/dist/src/transform/helpers.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +17 -2
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +66 -11
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/backoff.d.ts +48 -0
- package/dist/src/utility/backoff.d.ts.map +1 -0
- package/dist/src/utility/backoff.js +60 -0
- package/dist/src/utility/backoff.js.map +1 -0
- package/dist/src/utility/pending.d.ts +1 -0
- package/dist/src/utility/pending.d.ts.map +1 -1
- package/dist/src/utility/pending.js +3 -1
- package/dist/src/utility/pending.js.map +1 -1
- package/package.json +4 -4
- package/src/blocks/block-store.ts +19 -1
- package/src/blocks/helpers.ts +3 -3
- package/src/btree/btree.ts +108 -63
- package/src/chain/chain.ts +11 -1
- package/src/cluster/index.ts +1 -0
- package/src/cluster/membership.ts +85 -0
- package/src/cluster/structs.ts +51 -1
- package/src/cohort-topic/addressing.ts +12 -4
- package/src/cohort-topic/antidos/bootstrap-evidence.ts +21 -9
- package/src/cohort-topic/antidos/replay-guard.ts +37 -0
- package/src/cohort-topic/antidos/topic-budget.ts +17 -2
- package/src/cohort-topic/coldstart.ts +48 -15
- package/src/cohort-topic/gossip/bus.ts +39 -2
- package/src/cohort-topic/gossip/view.ts +26 -1
- package/src/cohort-topic/member-engine.ts +36 -15
- package/src/cohort-topic/membership/publisher.ts +25 -33
- package/src/cohort-topic/membership/verifier.ts +119 -3
- package/src/cohort-topic/promotion.ts +23 -2
- package/src/cohort-topic/registration/renewal.ts +73 -1
- package/src/cohort-topic/registration/types.ts +15 -0
- package/src/cohort-topic/service.ts +41 -15
- package/src/cohort-topic/sig/payloads.ts +29 -9
- package/src/cohort-topic/traffic.ts +23 -1
- package/src/cohort-topic/walk.ts +95 -24
- package/src/cohort-topic/wire/codec.ts +12 -0
- package/src/cohort-topic/wire/index.ts +2 -0
- package/src/cohort-topic/wire/payloads.ts +14 -5
- package/src/cohort-topic/wire/primitives.ts +188 -0
- package/src/cohort-topic/wire/types.ts +136 -4
- package/src/cohort-topic/wire/validate.ts +168 -134
- package/src/collection/collection.ts +123 -40
- package/src/collection/struct.ts +44 -2
- package/src/collections/tree/tree.ts +42 -5
- package/src/index.ts +1 -0
- package/src/log/log.ts +37 -9
- package/src/log/struct.ts +5 -4
- package/src/matchmaking/wire.ts +25 -102
- package/src/network/i-key-network.ts +10 -0
- package/src/network/i-repo.ts +8 -0
- package/src/network/struct.ts +10 -0
- package/src/reactivity/push-state.ts +4 -37
- package/src/reactivity/wire-validate.ts +7 -81
- package/src/reactivity/wire.ts +3 -3
- package/src/testing/async-wait.ts +65 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/test-transactor.ts +468 -0
- package/src/transaction/actions-engine.ts +8 -6
- package/src/transaction/coordinator.ts +498 -229
- package/src/transaction/errors.ts +91 -0
- package/src/transaction/index.ts +23 -3
- package/src/transaction/operations-hash.ts +196 -0
- package/src/transaction/read-dependency-collector.ts +78 -0
- package/src/transaction/session.ts +37 -9
- package/src/transaction/transaction.ts +211 -4
- package/src/transaction/validator.ts +73 -48
- package/src/transactor/network-transactor.ts +164 -49
- package/src/transactor/transactor-source.ts +47 -12
- package/src/transform/atomic-proxy.ts +92 -49
- package/src/transform/cache-source.ts +87 -9
- package/src/transform/helpers.ts +8 -2
- package/src/transform/tracker.ts +69 -11
- package/src/utility/backoff.ts +95 -0
- package/src/utility/pending.ts +3 -1
- package/src/transaction/context.ts +0 -103
|
@@ -21,6 +21,14 @@ export interface MemberContribution {
|
|
|
21
21
|
readonly windowSeconds: number;
|
|
22
22
|
/** Exact per-topic summaries this member last reported. */
|
|
23
23
|
readonly topicSummaries: readonly CohortTopicSummary[];
|
|
24
|
+
/**
|
|
25
|
+
* `topicId` (base64url) → summary, derived from {@link topicSummaries} at {@link MutableCohortView.merge}
|
|
26
|
+
* so a per-topic reader (traffic `snapshot`) does an O(1) lookup instead of an O(summaries) `.find` per
|
|
27
|
+
* member per reply. First occurrence wins on a duplicate `topicId` (matching `Array.prototype.find`).
|
|
28
|
+
* Populated by `merge`; a contribution built without going through `merge` may omit it, in which case
|
|
29
|
+
* consumers fall back to scanning {@link topicSummaries}.
|
|
30
|
+
*/
|
|
31
|
+
readonly topicIndex?: ReadonlyMap<string, CohortTopicSummary>;
|
|
24
32
|
/** Gossip timestamp (unix ms) — the last-writer-wins key. */
|
|
25
33
|
readonly timestamp: number;
|
|
26
34
|
}
|
|
@@ -55,11 +63,28 @@ class MapCohortView implements MutableCohortView {
|
|
|
55
63
|
if (held !== undefined && c.timestamp < held.timestamp) {
|
|
56
64
|
return false; // stale; keep the newer contribution
|
|
57
65
|
}
|
|
58
|
-
|
|
66
|
+
// Derive the per-topic index once, at write time, so per-topic readers never rescan the flat
|
|
67
|
+
// `topicSummaries` array per reply. A writer pays O(summaries) once per merge (gossip round).
|
|
68
|
+
this.byMember.set(member, { ...c, topicIndex: indexTopics(c.topicSummaries) });
|
|
59
69
|
return true;
|
|
60
70
|
}
|
|
61
71
|
}
|
|
62
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Index a member's flat topic summaries by `topicId` (base64url). First occurrence wins on a duplicate
|
|
75
|
+
* `topicId`, exactly matching the `Array.prototype.find` scan this replaces — so a snapshot over the index
|
|
76
|
+
* returns identical numbers to one over the array.
|
|
77
|
+
*/
|
|
78
|
+
function indexTopics(summaries: readonly CohortTopicSummary[]): ReadonlyMap<string, CohortTopicSummary> {
|
|
79
|
+
const index = new Map<string, CohortTopicSummary>();
|
|
80
|
+
for (const s of summaries) {
|
|
81
|
+
if (!index.has(s.topicId)) {
|
|
82
|
+
index.set(s.topicId, s);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return index;
|
|
86
|
+
}
|
|
87
|
+
|
|
63
88
|
/** Construct an empty {@link MutableCohortView}. */
|
|
64
89
|
export function createCohortView(): MutableCohortView {
|
|
65
90
|
return new MapCohortView();
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* on it (the activity callback in `docs/cohort-topic.md` §FRET integration). It is the cohort-facing
|
|
6
6
|
* half of the substrate, composed from the prereq tickets' pure modules — the participant-facing half
|
|
7
7
|
* lives in {@link import("./service.js").CohortTopicService}. db-p2p's FRET host decodes an inbound
|
|
8
|
-
* frame, supplies the routing context this module can't see (`followOn
|
|
9
|
-
*
|
|
8
|
+
* frame, supplies the routing context this module can't see (`followOn` — derived from the wire
|
|
9
|
+
* `RegisterV1.followOn` flag, evidence-gated in step 1 — plus the quorum-willing tally), and serializes
|
|
10
|
+
* whatever reply this returns back over the wire. db-core never imports FRET here.
|
|
10
11
|
*
|
|
11
12
|
* Inbound `RegisterV1` decision pipeline (§Registration mechanics, §Willingness, §Cold-start):
|
|
12
13
|
*
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
import type { IRingHash } from "./ports.js";
|
|
27
28
|
import { bytesEqual, bytesKey } from "./registration/bytes.js";
|
|
28
29
|
import type { RegistrationRecord, RegistrationStore } from "./registration/types.js";
|
|
29
|
-
import {
|
|
30
|
+
import { clampTtl } from "./registration/types.js";
|
|
30
31
|
import type { SlotAssigner } from "./registration/sharding.js";
|
|
31
32
|
import type { RenewalCohortSide } from "./registration/renewal.js";
|
|
32
33
|
import type { WillingnessCheck } from "./willingness.js";
|
|
@@ -47,7 +48,13 @@ import type { DemotionNoticeV1, PromotionNoticeV1, RegisterReplyV1, RegisterV1,
|
|
|
47
48
|
|
|
48
49
|
/** Routing context the FRET host supplies for an inbound register (db-core can't derive it). */
|
|
49
50
|
export interface RegisterContext {
|
|
50
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Did this register arrive as a follow-on to a parent cohort's `Promoted` redirect? The host derives
|
|
53
|
+
* this from the wire `RegisterV1.followOn` flag (`followOn: reg.followOn === true`). It is participant-
|
|
54
|
+
* asserted and therefore evidence-gated: `runGuards` (step 1) already ran `bootstrapEvidence.verify`,
|
|
55
|
+
* which demands the same proof for `followOn: true` as for `bootstrap: true`, so an unbacked follow-on
|
|
56
|
+
* never reaches this instantiation decision (it short-circuited to `unwilling_cohort`).
|
|
57
|
+
*/
|
|
51
58
|
readonly followOn: boolean;
|
|
52
59
|
/** Tree tier `d` this cohort serves the topic at (the walk position the register landed on). */
|
|
53
60
|
readonly treeTier: number;
|
|
@@ -172,7 +179,10 @@ class StoreCohortMemberEngine implements CohortMemberEngine {
|
|
|
172
179
|
return this.admitOrDecline(reg, topicId, participantId, tier, ctx, now);
|
|
173
180
|
}
|
|
174
181
|
|
|
175
|
-
// 3. Cold path: instantiate only at a legitimate growth point with a willing quorum.
|
|
182
|
+
// 3. Cold path: instantiate only at a legitimate growth point with a willing quorum. `ctx.followOn`
|
|
183
|
+
// mirrors the wire `reg.followOn` flag (the host derives it), and by the time we reach here the
|
|
184
|
+
// step-1 bootstrap-evidence gate has already verified a `followOn: true` carries the same proof a
|
|
185
|
+
// `bootstrap: true` root cold-start carries — so this branch never instantiates an unbacked follow-on.
|
|
176
186
|
const quorumWilling = this.deps.quorumWilling(tier);
|
|
177
187
|
if (!shouldInstantiate({ bootstrap: reg.bootstrap === true, followOn: ctx.followOn, quorumWilling })) {
|
|
178
188
|
return { v: 1, result: "no_state" };
|
|
@@ -310,7 +320,7 @@ class StoreCohortMemberEngine implements CohortMemberEngine {
|
|
|
310
320
|
): Promise<RegisterReplyV1> {
|
|
311
321
|
const { members, cohortEpoch } = this.deps.cohort();
|
|
312
322
|
const { primary, backups } = this.deps.slots.assignSlots(participantId, cohortEpoch, members);
|
|
313
|
-
const ttl = reg.ttl
|
|
323
|
+
const ttl = clampTtl(reg.ttl);
|
|
314
324
|
const record: RegistrationRecord = {
|
|
315
325
|
topicId,
|
|
316
326
|
participantId,
|
|
@@ -368,21 +378,32 @@ class StoreCohortMemberEngine implements CohortMemberEngine {
|
|
|
368
378
|
): RegisterReplyV1 | undefined {
|
|
369
379
|
if (this.deps.verifyRegisterSig?.(reg) === false) {
|
|
370
380
|
// Unsigned / forged participant signature: serve nothing and record nothing (the cohort
|
|
371
|
-
// cannot trust this participantCoord).
|
|
372
|
-
//
|
|
381
|
+
// cannot trust this participantCoord). Cheap + stateless, so checked first — a forged frame
|
|
382
|
+
// cannot consume any downstream guard state.
|
|
373
383
|
return { v: 1, result: "no_state" };
|
|
374
384
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
385
|
+
// Rate-check BEFORE the replay guard records anything: a fresh-correlationId spam frame that the
|
|
386
|
+
// rate limiter would reject must not first insert a `seen` entry (which would let an attacker grow
|
|
387
|
+
// the replay guard's memory at full attack speed regardless of the rate limit). Running it before
|
|
388
|
+
// the potentially-expensive bootstrap verify (which may do PoW) also short-circuits floods sooner.
|
|
389
|
+
// NOTE: a replayed frame now consumes a rate-limiter accept before the replay guard (below) drops it,
|
|
390
|
+
// whereas the old order dropped replays pre-rate. Fine for spam (inbound traffic should be counted);
|
|
391
|
+
// only matters if a legitimate client reuses one correlationId across retransmits — it would then
|
|
392
|
+
// burn rate budget per retransmit. If clients adopt idempotent same-cid retry, revisit this order.
|
|
393
|
+
const rate = this.deps.rateLimiter?.check(participantId, topicId, now);
|
|
394
|
+
if (rate !== undefined && rate.ok === false) {
|
|
395
|
+
return { v: 1, result: "unwilling_cohort", retryAfterMs: rate.retryAfterMs };
|
|
379
396
|
}
|
|
380
397
|
if (this.deps.bootstrapEvidence?.verify(reg, tier) === false) {
|
|
381
398
|
return this.unwillingCohort(reg, now);
|
|
382
399
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
400
|
+
// Replay guard records LAST, so only frames that passed signature + rate + bootstrap ever insert a
|
|
401
|
+
// `seen` entry. An *accepted* frame is still always recorded here, so a genuine replay of a served
|
|
402
|
+
// correlationId is still caught; only rate-rejected frames (never served, never recorded) skip it.
|
|
403
|
+
const correlationId = b64urlToBytes(reg.correlationId);
|
|
404
|
+
if (this.deps.replayGuard?.accept(correlationId, participantId, reg.timestamp, now) === false) {
|
|
405
|
+
// Stale / future-skewed / replayed: serve nothing and record nothing.
|
|
406
|
+
return { v: 1, result: "no_state" };
|
|
386
407
|
}
|
|
387
408
|
return undefined;
|
|
388
409
|
}
|
|
@@ -4,20 +4,21 @@
|
|
|
4
4
|
* Per `docs/cohort-topic.md` §Membership snapshots: the cohort publishes a threshold-signed
|
|
5
5
|
* `MembershipCertV1`
|
|
6
6
|
*
|
|
7
|
-
* - at stabilization, and on any change to the
|
|
7
|
+
* - at stabilization, and on any change to the cohort identity — the epoch `H(sorted all members)`,
|
|
8
|
+
* so *any* member change (head or tail) forces a fresh publish ({@link MembershipCertPublisher.onStabilized});
|
|
8
9
|
* - refreshed every `T_membership_refresh` (default 5 min) ({@link MembershipCertPublisher.tick}).
|
|
9
10
|
*
|
|
10
11
|
* The publisher is db-core logic over the {@link CohortSigner} (threshold sign) and the
|
|
11
12
|
* {@link IMembershipPublishSink} (serve/advertise). It never imports FRET. Members are sorted
|
|
12
|
-
* ascending for the cert (matching the sharding order)
|
|
13
|
-
*
|
|
13
|
+
* ascending for the cert (matching the sharding order); the snapshot's `cohortEpoch` is the full
|
|
14
|
+
* cohort identity, and a change to it across a publish forces a fresh publish.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import type { IMembershipPublishSink } from "../ports.js";
|
|
17
18
|
import { bytesToB64url, encodeCohortMessage } from "../wire/codec.js";
|
|
18
19
|
import type { MembershipCertV1 } from "../wire/types.js";
|
|
19
20
|
import { compareBytes } from "../registration/bytes.js";
|
|
20
|
-
import {
|
|
21
|
+
import { type CohortSigner } from "../sig/threshold.js";
|
|
21
22
|
import { membershipCertSigningPayload, type MembershipCertSignable } from "../sig/payloads.js";
|
|
22
23
|
|
|
23
24
|
/** Default membership-cert refresh interval (`T_membership_refresh`). */
|
|
@@ -74,10 +75,11 @@ export interface RotationAttestation {
|
|
|
74
75
|
/** Cohort-side membership-cert publisher. */
|
|
75
76
|
export interface MembershipCertPublisher {
|
|
76
77
|
/**
|
|
77
|
-
* Drive publication on a stabilization event. Publishes (and returns the cert) when the
|
|
78
|
-
* `
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
* Drive publication on a stabilization event. Publishes (and returns the cert) when the cohort identity
|
|
79
|
+
* (`cohortEpoch = H(sorted members)`) changed since the last publish — i.e. on **any** member change,
|
|
80
|
+
* head or tail — or on the first call; otherwise returns `undefined`. Pass `rotation` to attach a
|
|
81
|
+
* predecessor-cohort rotation attestation (an epoch rotation); omit it for the default non-rotation
|
|
82
|
+
* publish (no rotation fields are emitted).
|
|
81
83
|
*/
|
|
82
84
|
onStabilized(snapshot: CohortSnapshot, now: number, rotation?: RotationAttestation): Promise<MembershipCertV1 | undefined>;
|
|
83
85
|
/**
|
|
@@ -91,26 +93,33 @@ export interface MembershipCertPublisherDeps {
|
|
|
91
93
|
signer: CohortSigner;
|
|
92
94
|
sink: IMembershipPublishSink;
|
|
93
95
|
refreshMs?: number;
|
|
96
|
+
/**
|
|
97
|
+
* Accepted for call-site compatibility but no longer read by the publisher: the republish gate now keys
|
|
98
|
+
* on the full `cohortEpoch`, not the first `k − x` members, and the threshold-sign quorum lives in the
|
|
99
|
+
* {@link CohortSigner}. Kept so existing callers (host + db-p2p tests) compile unchanged.
|
|
100
|
+
*/
|
|
94
101
|
minSigs?: number;
|
|
95
102
|
maxMessageBytes?: number;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
class SigningMembershipCertPublisher implements MembershipCertPublisher {
|
|
99
106
|
private readonly refreshMs: number;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
private lastFirstKx: string[] | undefined;
|
|
107
|
+
/** base64url of the last published snapshot's `cohortEpoch` — the full cohort-identity republish key. */
|
|
108
|
+
private lastEpoch: string | undefined;
|
|
103
109
|
private lastPublishedAt: number | undefined;
|
|
104
110
|
|
|
105
111
|
constructor(private readonly deps: MembershipCertPublisherDeps) {
|
|
106
112
|
this.refreshMs = deps.refreshMs ?? DEFAULT_T_MEMBERSHIP_REFRESH_MS;
|
|
107
|
-
this.minSigs = deps.minSigs ?? DEFAULT_MIN_SIGS;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
async onStabilized(snapshot: CohortSnapshot, now: number, rotation?: RotationAttestation): Promise<MembershipCertV1 | undefined> {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
// NOTE: republish (and the caller's rotation attestation) now fire on ANY epoch change — any member
|
|
117
|
+
// change, head or tail — not just first-(k − x) churn. Gated on an actual epoch change, never per
|
|
118
|
+
// tick, so cost is bounded by real churn. If a high-churn cohort ever shows excess `/sign` or publish
|
|
119
|
+
// load, reconsider a short debounce / batching here.
|
|
120
|
+
const epoch = bytesToB64url(snapshot.cohortEpoch);
|
|
121
|
+
if (this.lastEpoch !== undefined && this.lastEpoch === epoch) {
|
|
122
|
+
return undefined; // cohort identity unchanged — no republish needed
|
|
114
123
|
}
|
|
115
124
|
return this.publish(snapshot, now, rotation);
|
|
116
125
|
}
|
|
@@ -122,11 +131,6 @@ class SigningMembershipCertPublisher implements MembershipCertPublisher {
|
|
|
122
131
|
return this.publish(snapshot, now, rotation);
|
|
123
132
|
}
|
|
124
133
|
|
|
125
|
-
/** The first `minSigs` members of the cert's ascending order, base64url — the republish-change key. */
|
|
126
|
-
private firstKx(members: readonly Uint8Array[]): string[] {
|
|
127
|
-
return [...members].sort(compareBytes).slice(0, this.minSigs).map(bytesToB64url);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
134
|
private async publish(snapshot: CohortSnapshot, now: number, rotation?: RotationAttestation): Promise<MembershipCertV1> {
|
|
131
135
|
const signable = membershipCertSignable(snapshot);
|
|
132
136
|
const { thresholdSig, signers } = await this.deps.signer.thresholdSign(membershipCertSigningPayload(signable));
|
|
@@ -147,24 +151,12 @@ class SigningMembershipCertPublisher implements MembershipCertPublisher {
|
|
|
147
151
|
cert.rotationSigners = rotation.rotationSigners.map(bytesToB64url);
|
|
148
152
|
}
|
|
149
153
|
this.deps.sink.publish(encodeCohortMessage(cert, this.deps.maxMessageBytes));
|
|
150
|
-
this.
|
|
154
|
+
this.lastEpoch = bytesToB64url(snapshot.cohortEpoch);
|
|
151
155
|
this.lastPublishedAt = now;
|
|
152
156
|
return cert;
|
|
153
157
|
}
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
function sameOrder(a: readonly string[], b: readonly string[]): boolean {
|
|
157
|
-
if (a.length !== b.length) {
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
160
|
-
for (let i = 0; i < a.length; i++) {
|
|
161
|
-
if (a[i] !== b[i]) {
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
160
|
/** Build a cohort-side {@link MembershipCertPublisher}. */
|
|
169
161
|
export function createMembershipCertPublisher(deps: MembershipCertPublisherDeps): MembershipCertPublisher {
|
|
170
162
|
return new SigningMembershipCertPublisher(deps);
|
|
@@ -52,6 +52,15 @@ import type { MembershipCertV1 } from "../wire/types.js";
|
|
|
52
52
|
import { DEFAULT_MIN_SIGS, type CohortSigner } from "../sig/threshold.js";
|
|
53
53
|
import { membershipCertSigningPayload } from "../sig/payloads.js";
|
|
54
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;
|
|
55
64
|
|
|
56
65
|
/** Outcome of verifying a threshold-signed message against cohort membership. */
|
|
57
66
|
export type VerifyResult = "verified" | "untrusted";
|
|
@@ -99,6 +108,34 @@ export interface MembershipVerifierDeps {
|
|
|
99
108
|
anchor?: IMembershipTrustAnchor;
|
|
100
109
|
/** Out-of-band-seeded genesis trust roots (the base case of every attestation chain). Defaults to `[]`. */
|
|
101
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;
|
|
102
139
|
}
|
|
103
140
|
|
|
104
141
|
/** Result of the trust gate: accept as a trusted anchor, accept as interim TOFU, or reject outright. */
|
|
@@ -119,17 +156,40 @@ interface NormalizedTrustRoot {
|
|
|
119
156
|
}
|
|
120
157
|
|
|
121
158
|
class CachingMembershipVerifier implements MembershipVerifier {
|
|
122
|
-
|
|
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>;
|
|
123
168
|
/** Per-coord timestamp of the last `source.fetch()` attempt (the rate-limit clock for {@link RefetchBound}). */
|
|
124
|
-
private readonly lastFetchAt
|
|
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>;
|
|
125
176
|
private readonly minSigs: number;
|
|
126
177
|
private readonly anchor: IMembershipTrustAnchor;
|
|
127
178
|
private readonly trustRoots: readonly NormalizedTrustRoot[];
|
|
179
|
+
private readonly staleGapRecoveryStrikes: number;
|
|
128
180
|
|
|
129
181
|
constructor(private readonly deps: MembershipVerifierDeps) {
|
|
130
182
|
this.minSigs = deps.minSigs ?? DEFAULT_MIN_SIGS;
|
|
131
183
|
this.anchor = deps.anchor ?? noAuthorityTrustAnchor;
|
|
132
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);
|
|
133
193
|
}
|
|
134
194
|
|
|
135
195
|
cache(cert: MembershipCertV1): void {
|
|
@@ -149,6 +209,7 @@ class CachingMembershipVerifier implements MembershipVerifier {
|
|
|
149
209
|
cert = await this.loadFrom(source.current(expectedCoord), tier);
|
|
150
210
|
}
|
|
151
211
|
if (cert !== undefined && this.messageVerifies(cert, signers, payload, sig)) {
|
|
212
|
+
this.staleGapStrikes.delete(coordKey); // a verify resets the consecutive stale-gap strike count
|
|
152
213
|
return "verified";
|
|
153
214
|
}
|
|
154
215
|
|
|
@@ -162,6 +223,7 @@ class CachingMembershipVerifier implements MembershipVerifier {
|
|
|
162
223
|
}
|
|
163
224
|
const refreshed = await this.loadFrom(source.fetch(expectedCoord), tier);
|
|
164
225
|
if (refreshed !== undefined && this.messageVerifies(refreshed, signers, payload, sig)) {
|
|
226
|
+
this.staleGapStrikes.delete(coordKey); // a verify resets the consecutive stale-gap strike count
|
|
165
227
|
return "verified";
|
|
166
228
|
}
|
|
167
229
|
return "untrusted";
|
|
@@ -242,7 +304,61 @@ class CachingMembershipVerifier implements MembershipVerifier {
|
|
|
242
304
|
if (this.hasRotationAttestation(cert) && this.chainGrantsTrust(cert)) {
|
|
243
305
|
return "trusted";
|
|
244
306
|
}
|
|
245
|
-
|
|
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";
|
|
246
362
|
}
|
|
247
363
|
|
|
248
364
|
/**
|
|
@@ -124,6 +124,13 @@ export interface PromotionDeps {
|
|
|
124
124
|
treeTier: (topicId: Uint8Array) => number;
|
|
125
125
|
/** The tier-`(d − 1)` parent cohort coord, target of a {@link DemotionNoticeV1}. */
|
|
126
126
|
parentCoord: (topicId: Uint8Array) => Uint8Array;
|
|
127
|
+
/**
|
|
128
|
+
* The served coord `coord_d(participantCoord, topicId)` this cohort sits at (raw bytes) — stamped on
|
|
129
|
+
* every notice as `cohortCoord` and covered by its threshold signature, so a receiver routes the notice
|
|
130
|
+
* to exactly this cohort's engine and verifies it against exactly this cohort's cert. Constant for the
|
|
131
|
+
* engine's lifetime (the engine is instantiated at one served coord).
|
|
132
|
+
*/
|
|
133
|
+
cohortCoord: () => Uint8Array;
|
|
127
134
|
/** Cohort epoch (raw bytes) the notices are signed under. */
|
|
128
135
|
cohortEpoch: () => Uint8Array;
|
|
129
136
|
/** Threshold signer (the gossip ticket's `k − x` cohort signer). */
|
|
@@ -281,9 +288,13 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
|
281
288
|
|
|
282
289
|
private async promote(topicId: Uint8Array, state: PromotionState, now: number): Promise<PromotionNoticeV1> {
|
|
283
290
|
const fromTier = this.deps.treeTier(topicId);
|
|
291
|
+
// NOTE: no guard here against fromTier === DEFAULT_D_MAX_CAP (60). At that depth toTier = 61 exceeds the
|
|
292
|
+
// tree-tier ceiling, so validatePromotionNoticeV1 rejects the notice on every receiver. Unreachable today
|
|
293
|
+
// (tree tier 60 is pathological); if the tree can ever reach the cap, gate promotion below the cap here.
|
|
284
294
|
const topicB64 = bytesToB64url(topicId);
|
|
285
295
|
const epochB64 = bytesToB64url(this.deps.cohortEpoch());
|
|
286
|
-
const
|
|
296
|
+
const cohortB64 = bytesToB64url(this.deps.cohortCoord());
|
|
297
|
+
const signable = { topicId: topicB64, fromTier, toTier: fromTier + 1, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
|
|
287
298
|
const { thresholdSig, signers } = await this.deps.signer.thresholdSign(promotionNoticeSigningPayload(signable));
|
|
288
299
|
state.promoted = true;
|
|
289
300
|
state.promotedAt = now;
|
|
@@ -293,6 +304,7 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
|
293
304
|
topicId: topicB64,
|
|
294
305
|
fromTier,
|
|
295
306
|
toTier: fromTier + 1,
|
|
307
|
+
cohortCoord: cohortB64,
|
|
296
308
|
effectiveAt: now,
|
|
297
309
|
thresholdSig: bytesToB64url(thresholdSig),
|
|
298
310
|
signers: signers.map(bytesToB64url),
|
|
@@ -329,10 +341,18 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
|
329
341
|
const topicB64 = bytesToB64url(topicId);
|
|
330
342
|
const epochB64 = bytesToB64url(this.deps.cohortEpoch());
|
|
331
343
|
const parentB64 = bytesToB64url(this.deps.parentCoord(topicId));
|
|
332
|
-
const
|
|
344
|
+
const cohortB64 = bytesToB64url(this.deps.cohortCoord());
|
|
345
|
+
const signable = { topicId: topicB64, tier, parentCohortCoord: parentB64, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
|
|
333
346
|
const { thresholdSig, signers } = await this.deps.signer.thresholdSign(demotionNoticeSigningPayload(signable));
|
|
334
347
|
// Release forwarder state: a demoted cohort leaves promoted mode and resets its clocks so a
|
|
335
348
|
// later re-growth re-evaluates cleanly.
|
|
349
|
+
// NOTE: demotion currently resets only the promoted-bounce clocks; it does NOT stop the node serving
|
|
350
|
+
// the topic — the cold-start forwarder, the direct-participant records, and the budget slot all stay,
|
|
351
|
+
// and the cohort keeps serving at tier d. If demotion is ever made to actually collapse local tier
|
|
352
|
+
// state, it must reassign or drain the topic's records FIRST, then `coldStart.remove` + release the
|
|
353
|
+
// budget slot + `traffic.forget`, in that order — removing the forwarder while records remain re-creates
|
|
354
|
+
// the off-budget-serving drift fixed in `cohort-topic-coldstart-forwarder-reconcile` (the budget stops
|
|
355
|
+
// bounding the served-topic set). The eviction path already reconciles all three via `TopicBudget.onEvict`.
|
|
336
356
|
state.promoted = false;
|
|
337
357
|
state.promotedAt = undefined;
|
|
338
358
|
state.lowLoadSince = undefined;
|
|
@@ -342,6 +362,7 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
|
342
362
|
topicId: topicB64,
|
|
343
363
|
tier,
|
|
344
364
|
parentCohortCoord: parentB64,
|
|
365
|
+
cohortCoord: cohortB64,
|
|
345
366
|
effectiveAt: now,
|
|
346
367
|
thresholdSig: bytesToB64url(thresholdSig),
|
|
347
368
|
signers: signers.map(bytesToB64url),
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* in isolation; db-core never imports FRET or libp2p here.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { DEFAULT_REPLAY_MAX_AGE_MS, DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS } from "../antidos/replay-guard.js";
|
|
23
24
|
import { b64urlToBytes } from "../wire/codec.js";
|
|
24
25
|
import type { RenewReplyV1, RenewV1 } from "../wire/types.js";
|
|
25
26
|
import { bytesEqual, bytesKey, recordKey } from "./bytes.js";
|
|
@@ -280,6 +281,21 @@ export interface RenewalCohortSideDeps {
|
|
|
280
281
|
* only touch `lastPing`).
|
|
281
282
|
*/
|
|
282
283
|
verifyParticipantSig?: (renew: RenewV1) => boolean;
|
|
284
|
+
/**
|
|
285
|
+
* Optional freshness window for the two privileged, participant-attested paths (`withdraw` eviction and
|
|
286
|
+
* `reattach` promotion). The renew signature already binds `timestamp` (see
|
|
287
|
+
* {@link import("../wire/payloads.js").renewSigningPayload}), so an attacker cannot forge a fresher
|
|
288
|
+
* timestamp onto a captured frame — replay is exact-frame only. A timestamp gate is therefore a
|
|
289
|
+
* complete freshness regime for these frames: it rejects a stale/implausibly-future `timestamp`, and a
|
|
290
|
+
* per-record monotonic check (`timestamp <= rec.lastPing`) closes the sub-`maxAge` fast-replay window
|
|
291
|
+
* using state already on the record. Defaults to the register-path skew constants
|
|
292
|
+
* ({@link DEFAULT_REPLAY_MAX_AGE_MS} / {@link DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS}); db-p2p wires the same
|
|
293
|
+
* `{ maxAgeMs, maxFutureSkewMs }` config the register path's replay guard consumes so an operator tuning
|
|
294
|
+
* the skew window moves both paths together. Absent → defaults (key-less unit tests and callers work
|
|
295
|
+
* unchanged, matching how {@link verifyParticipantSig} is optional). Plain pings are NOT gated (a
|
|
296
|
+
* replayed ping is low-harm — it only re-touches `lastPing`).
|
|
297
|
+
*/
|
|
298
|
+
freshness?: { maxAgeMs?: number; maxFutureSkewMs?: number };
|
|
283
299
|
}
|
|
284
300
|
|
|
285
301
|
/** Cohort-side TTL handling: touch on renew, redirect on rotation, sweep stale records. */
|
|
@@ -300,7 +316,45 @@ class StoreRenewalCohortSide implements RenewalCohortSide {
|
|
|
300
316
|
*/
|
|
301
317
|
private readonly failoverServing = new Map<string, Uint8Array>();
|
|
302
318
|
|
|
303
|
-
|
|
319
|
+
/** Resolved staleness window / forward-skew tolerance for the privileged freshness gate. */
|
|
320
|
+
private readonly maxAgeMs: number;
|
|
321
|
+
private readonly maxFutureSkewMs: number;
|
|
322
|
+
|
|
323
|
+
constructor(private readonly deps: RenewalCohortSideDeps) {
|
|
324
|
+
this.maxAgeMs = deps.freshness?.maxAgeMs ?? DEFAULT_REPLAY_MAX_AGE_MS;
|
|
325
|
+
this.maxFutureSkewMs = deps.freshness?.maxFutureSkewMs ?? DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Freshness gate for the two privileged, participant-attested branches (`withdraw`, `reattach`). Returns
|
|
330
|
+
* `false` — reject — when `msg.timestamp` is stale (older than `now − maxAgeMs`), implausibly future
|
|
331
|
+
* (newer than `now + maxFutureSkewMs`), or not strictly newer than the record's `lastPing`. The signed
|
|
332
|
+
* timestamp is immutable to a replayer, so this fully bounds a captured privileged frame: the skew
|
|
333
|
+
* window catches an old capture, and the `<= rec.lastPing` monotonic check catches a fast replay that
|
|
334
|
+
* still fits inside the window (e.g. a `withdraw` captured at `t0` replayed after the record was
|
|
335
|
+
* re-registered at `t_reregister > t0`, or a `reattach` replayed after its own accepted re-stamp
|
|
336
|
+
* already advanced `lastPing`).
|
|
337
|
+
*/
|
|
338
|
+
private isFreshPrivileged(msg: RenewV1, rec: RegistrationRecord, now: number): boolean {
|
|
339
|
+
if (msg.timestamp < now - this.maxAgeMs) {
|
|
340
|
+
return false; // stale
|
|
341
|
+
}
|
|
342
|
+
if (msg.timestamp > now + this.maxFutureSkewMs) {
|
|
343
|
+
return false; // implausibly future
|
|
344
|
+
}
|
|
345
|
+
// NOTE: condition (3) compares a participant-supplied `msg.timestamp` against a server-maintained
|
|
346
|
+
// `rec.lastPing` (set from whichever cohort member last touched the record, using its own clock) — two
|
|
347
|
+
// different machines' clocks. In normal operation a genuine leave/failover post-dates the last ping, so
|
|
348
|
+
// `timestamp > lastPing` holds. If a participant's clock lags the server that set `lastPing`, a
|
|
349
|
+
// legitimate reattach can be rejected; the failure is soft (returns `primary_moved`, so the failover
|
|
350
|
+
// loop tries the next backup / re-runs the d_max lookup — a delayed failover, not data loss). If
|
|
351
|
+
// cross-node skew is ever observed to stall failovers, relax (3) to strict `<` (accept `timestamp ==
|
|
352
|
+
// lastPing`); the `maxAge` window still backstops the replay-after-re-registration attack.
|
|
353
|
+
if (msg.timestamp <= rec.lastPing) {
|
|
354
|
+
return false; // replay / non-monotonic against the live record
|
|
355
|
+
}
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
304
358
|
|
|
305
359
|
onRenew(msg: RenewV1, now: number): RenewReplyV1 {
|
|
306
360
|
const topicId = b64(msg.topicId);
|
|
@@ -321,6 +375,13 @@ class StoreRenewalCohortSide implements RenewalCohortSide {
|
|
|
321
375
|
if (this.deps.verifyParticipantSig?.(msg) === false) {
|
|
322
376
|
return { v: 1, result: "unknown_registration" };
|
|
323
377
|
}
|
|
378
|
+
// Freshness gate: a signed withdraw is valid forever without this, so a captured one could be
|
|
379
|
+
// replayed after the victim's record TTL-expires and re-registers, evicting the *fresh* record.
|
|
380
|
+
// Reject a stale/replayed frame with the same opaque `unknown_registration` the forged-sig branch
|
|
381
|
+
// returns above — indistinguishable from an untrusted frame — and never delete.
|
|
382
|
+
if (!this.isFreshPrivileged(msg, rec, now)) {
|
|
383
|
+
return { v: 1, result: "unknown_registration" };
|
|
384
|
+
}
|
|
324
385
|
this.deps.store.delete(topicId, participantId);
|
|
325
386
|
this.failoverServing.delete(key); // mirror sweepStale: drop any crash-failover override
|
|
326
387
|
this.deps.gossip.evicted(rec);
|
|
@@ -338,6 +399,12 @@ class StoreRenewalCohortSide implements RenewalCohortSide {
|
|
|
338
399
|
if (this.deps.verifyParticipantSig?.(msg) === false) {
|
|
339
400
|
return this.primaryMoved(primary, backups, cohortEpoch);
|
|
340
401
|
}
|
|
402
|
+
// Freshness gate: a signed reattach is valid forever without this, so a captured one could be
|
|
403
|
+
// replayed to force bogus primary re-stamps. Reject a stale/replayed frame with the same redirect
|
|
404
|
+
// the forged-sig branch returns above — revealing nothing — and never promote.
|
|
405
|
+
if (!this.isFreshPrivileged(msg, rec, now)) {
|
|
406
|
+
return this.primaryMoved(primary, backups, cohortEpoch);
|
|
407
|
+
}
|
|
341
408
|
if (bytesEqual(primary, self)) {
|
|
342
409
|
// A rotation already made this member the computed primary; serve, no override needed.
|
|
343
410
|
return this.touchAndServe(rec, now);
|
|
@@ -365,6 +432,11 @@ class StoreRenewalCohortSide implements RenewalCohortSide {
|
|
|
365
432
|
}
|
|
366
433
|
|
|
367
434
|
// Plain ping.
|
|
435
|
+
// NOTE: plain pings are deliberately NOT run through the privileged freshness gate (isFreshPrivileged).
|
|
436
|
+
// A replayed ping is low-harm — it can only re-touch a record's `lastPing`, never delete or usurp — and
|
|
437
|
+
// the strict `timestamp <= lastPing` monotonic check would risk rejecting a legitimate ping that arrives
|
|
438
|
+
// slightly out of order or under minor participant-clock non-monotonicity. If plain-ping replay ever
|
|
439
|
+
// becomes a concern (e.g. touch-driven traffic accounting is abused), gate it here too.
|
|
368
440
|
const isComputedPrimary = bytesEqual(primary, self);
|
|
369
441
|
const override = this.failoverServing.get(key);
|
|
370
442
|
const overrideMatches = override !== undefined && bytesEqual(override, cohortEpoch);
|
|
@@ -67,9 +67,24 @@ export interface RegistrationStore {
|
|
|
67
67
|
export const DEFAULT_TTL_MS = 90_000;
|
|
68
68
|
/** Edge-tier default registration TTL (ms). */
|
|
69
69
|
export const EDGE_TTL_MS = 60_000;
|
|
70
|
+
/** Minimum accepted registration TTL (ms). Requests below this are clamped up. */
|
|
71
|
+
export const MIN_TTL_MS = 10_000;
|
|
72
|
+
/** Maximum accepted registration TTL (ms). Requests above this are clamped down.
|
|
73
|
+
* 10 × DEFAULT_TTL_MS keeps the window predictable; prevents a wedged budget slot from a poison TTL. */
|
|
74
|
+
export const MAX_TTL_MS = 10 * DEFAULT_TTL_MS;
|
|
70
75
|
/** Consecutive ping failures before a participant promotes `backups[0]`. */
|
|
71
76
|
export const MAX_PING_FAILURES = 3;
|
|
72
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Clamp a requested or replicated TTL into the accepted `[MIN_TTL_MS, MAX_TTL_MS]` window.
|
|
80
|
+
* Non-positive input falls to {@link DEFAULT_TTL_MS} first. This is the single TTL policy gate:
|
|
81
|
+
* both local admission (`accept()`) and gossip replication (`mergeRecords`) run every TTL through
|
|
82
|
+
* it, so no store — local or replica — can ever hold a record whose lifetime dodges the cap.
|
|
83
|
+
*/
|
|
84
|
+
export function clampTtl(ttl: number): number {
|
|
85
|
+
return Math.min(Math.max(ttl > 0 ? ttl : DEFAULT_TTL_MS, MIN_TTL_MS), MAX_TTL_MS);
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
/** `ping_interval = ttl / 3` (default 30s Core, 20s Edge), floored to whole ms. */
|
|
74
89
|
export function pingIntervalMs(ttl: number): number {
|
|
75
90
|
return Math.floor(ttl / 3);
|