@optimystic/db-p2p 0.13.5 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/src/cluster/block-transfer-service.d.ts +67 -8
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +101 -62
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/client.d.ts +18 -1
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +55 -39
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-error.d.ts +38 -0
- package/dist/src/cluster/cluster-error.d.ts.map +1 -0
- package/dist/src/cluster/cluster-error.js +51 -0
- package/dist/src/cluster/cluster-error.js.map +1 -0
- package/dist/src/cluster/cluster-repo.d.ts +135 -3
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +333 -58
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/commit-cert.d.ts +63 -0
- package/dist/src/cluster/commit-cert.d.ts.map +1 -0
- package/dist/src/cluster/commit-cert.js +108 -0
- package/dist/src/cluster/commit-cert.js.map +1 -0
- package/dist/src/cluster/service.d.ts +46 -11
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +97 -33
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts +13 -0
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +34 -4
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cohort-topic/bootstrap-evidence-builder.d.ts +59 -0
- package/dist/src/cohort-topic/bootstrap-evidence-builder.d.ts.map +1 -0
- package/dist/src/cohort-topic/bootstrap-evidence-builder.js +79 -0
- package/dist/src/cohort-topic/bootstrap-evidence-builder.js.map +1 -0
- package/dist/src/cohort-topic/bootstrap-evidence-verifiers.d.ts +81 -0
- package/dist/src/cohort-topic/bootstrap-evidence-verifiers.d.ts.map +1 -0
- package/dist/src/cohort-topic/bootstrap-evidence-verifiers.js +93 -0
- package/dist/src/cohort-topic/bootstrap-evidence-verifiers.js.map +1 -0
- package/dist/src/cohort-topic/bootstrap-parent-reference.d.ts +108 -0
- package/dist/src/cohort-topic/bootstrap-parent-reference.d.ts.map +1 -0
- package/dist/src/cohort-topic/bootstrap-parent-reference.js +108 -0
- package/dist/src/cohort-topic/bootstrap-parent-reference.js.map +1 -0
- package/dist/src/cohort-topic/change-bridge.d.ts +65 -0
- package/dist/src/cohort-topic/change-bridge.d.ts.map +1 -0
- package/dist/src/cohort-topic/change-bridge.js +76 -0
- package/dist/src/cohort-topic/change-bridge.js.map +1 -0
- package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +74 -0
- package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -0
- package/dist/src/cohort-topic/cohort-gossip-driver.js +87 -0
- package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +42 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.js +57 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -0
- package/dist/src/cohort-topic/fret-trust-anchor.d.ts +100 -0
- package/dist/src/cohort-topic/fret-trust-anchor.d.ts.map +1 -0
- package/dist/src/cohort-topic/fret-trust-anchor.js +115 -0
- package/dist/src/cohort-topic/fret-trust-anchor.js.map +1 -0
- package/dist/src/cohort-topic/host.d.ts +573 -0
- package/dist/src/cohort-topic/host.d.ts.map +1 -0
- package/dist/src/cohort-topic/host.js +1460 -0
- package/dist/src/cohort-topic/host.js.map +1 -0
- package/dist/src/cohort-topic/index.d.ts +14 -0
- package/dist/src/cohort-topic/index.d.ts.map +1 -0
- package/dist/src/cohort-topic/index.js +14 -0
- package/dist/src/cohort-topic/index.js.map +1 -0
- package/dist/src/cohort-topic/membership-publish-sink.d.ts +14 -0
- package/dist/src/cohort-topic/membership-publish-sink.d.ts.map +1 -0
- package/dist/src/cohort-topic/membership-publish-sink.js +17 -0
- package/dist/src/cohort-topic/membership-publish-sink.js.map +1 -0
- package/dist/src/cohort-topic/membership-source.d.ts +36 -0
- package/dist/src/cohort-topic/membership-source.d.ts.map +1 -0
- package/dist/src/cohort-topic/membership-source.js +58 -0
- package/dist/src/cohort-topic/membership-source.js.map +1 -0
- package/dist/src/cohort-topic/peer-codec.d.ts +18 -0
- package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -0
- package/dist/src/cohort-topic/peer-codec.js +26 -0
- package/dist/src/cohort-topic/peer-codec.js.map +1 -0
- package/dist/src/cohort-topic/peer-sig.d.ts +52 -0
- package/dist/src/cohort-topic/peer-sig.d.ts.map +1 -0
- package/dist/src/cohort-topic/peer-sig.js +83 -0
- package/dist/src/cohort-topic/peer-sig.js.map +1 -0
- package/dist/src/cohort-topic/protocols.d.ts +49 -0
- package/dist/src/cohort-topic/protocols.d.ts.map +1 -0
- package/dist/src/cohort-topic/protocols.js +58 -0
- package/dist/src/cohort-topic/protocols.js.map +1 -0
- package/dist/src/cohort-topic/reactivity-membership-gate.d.ts +49 -0
- package/dist/src/cohort-topic/reactivity-membership-gate.d.ts.map +1 -0
- package/dist/src/cohort-topic/reactivity-membership-gate.js +42 -0
- package/dist/src/cohort-topic/reactivity-membership-gate.js.map +1 -0
- package/dist/src/cohort-topic/size-estimator.d.ts +16 -0
- package/dist/src/cohort-topic/size-estimator.d.ts.map +1 -0
- package/dist/src/cohort-topic/size-estimator.js +16 -0
- package/dist/src/cohort-topic/size-estimator.js.map +1 -0
- package/dist/src/cohort-topic/stream-util.d.ts +19 -0
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -0
- package/dist/src/cohort-topic/stream-util.js +76 -0
- package/dist/src/cohort-topic/stream-util.js.map +1 -0
- package/dist/src/cohort-topic/threshold-crypto.d.ts +94 -0
- package/dist/src/cohort-topic/threshold-crypto.d.ts.map +1 -0
- package/dist/src/cohort-topic/threshold-crypto.js +178 -0
- package/dist/src/cohort-topic/threshold-crypto.js.map +1 -0
- package/dist/src/cohort-topic/topic-router.d.ts +41 -0
- package/dist/src/cohort-topic/topic-router.d.ts.map +1 -0
- package/dist/src/cohort-topic/topic-router.js +63 -0
- package/dist/src/cohort-topic/topic-router.js.map +1 -0
- package/dist/src/dispute/cascade.d.ts +179 -0
- package/dist/src/dispute/cascade.d.ts.map +1 -0
- package/dist/src/dispute/cascade.js +315 -0
- package/dist/src/dispute/cascade.js.map +1 -0
- package/dist/src/dispute/client.d.ts +2 -1
- package/dist/src/dispute/client.d.ts.map +1 -1
- package/dist/src/dispute/client.js +12 -3
- package/dist/src/dispute/client.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts +41 -1
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +147 -21
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/dispute/index.d.ts +2 -0
- package/dist/src/dispute/index.d.ts.map +1 -1
- package/dist/src/dispute/index.js +2 -0
- package/dist/src/dispute/index.js.map +1 -1
- package/dist/src/dispute/invalidation.d.ts +266 -0
- package/dist/src/dispute/invalidation.d.ts.map +1 -0
- package/dist/src/dispute/invalidation.js +405 -0
- package/dist/src/dispute/invalidation.js.map +1 -0
- package/dist/src/dispute/types.d.ts +14 -0
- package/dist/src/dispute/types.d.ts.map +1 -1
- package/dist/src/dispute/types.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +7 -0
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +49 -5
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +45 -3
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +522 -5
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/matchmaking/aggregate-counts.d.ts +67 -0
- package/dist/src/matchmaking/aggregate-counts.d.ts.map +1 -0
- package/dist/src/matchmaking/aggregate-counts.js +60 -0
- package/dist/src/matchmaking/aggregate-counts.js.map +1 -0
- package/dist/src/matchmaking/index.d.ts +20 -0
- package/dist/src/matchmaking/index.d.ts.map +1 -0
- package/dist/src/matchmaking/index.js +20 -0
- package/dist/src/matchmaking/index.js.map +1 -0
- package/dist/src/matchmaking/module.d.ts +181 -0
- package/dist/src/matchmaking/module.d.ts.map +1 -0
- package/dist/src/matchmaking/module.js +221 -0
- package/dist/src/matchmaking/module.js.map +1 -0
- package/dist/src/matchmaking/protocols.d.ts +37 -0
- package/dist/src/matchmaking/protocols.d.ts.map +1 -0
- package/dist/src/matchmaking/protocols.js +42 -0
- package/dist/src/matchmaking/protocols.js.map +1 -0
- package/dist/src/matchmaking/provider-manager.d.ts +52 -0
- package/dist/src/matchmaking/provider-manager.d.ts.map +1 -0
- package/dist/src/matchmaking/provider-manager.js +74 -0
- package/dist/src/matchmaking/provider-manager.js.map +1 -0
- package/dist/src/matchmaking/query-handler.d.ts +34 -0
- package/dist/src/matchmaking/query-handler.d.ts.map +1 -0
- package/dist/src/matchmaking/query-handler.js +62 -0
- package/dist/src/matchmaking/query-handler.js.map +1 -0
- package/dist/src/matchmaking/query-transport.d.ts +180 -0
- package/dist/src/matchmaking/query-transport.d.ts.map +1 -0
- package/dist/src/matchmaking/query-transport.js +315 -0
- package/dist/src/matchmaking/query-transport.js.map +1 -0
- package/dist/src/matchmaking/seeker-manager.d.ts +39 -0
- package/dist/src/matchmaking/seeker-manager.d.ts.map +1 -0
- package/dist/src/matchmaking/seeker-manager.js +49 -0
- package/dist/src/matchmaking/seeker-manager.js.map +1 -0
- package/dist/src/matchmaking/seeker-walk-client.d.ts +127 -0
- package/dist/src/matchmaking/seeker-walk-client.d.ts.map +1 -0
- package/dist/src/matchmaking/seeker-walk-client.js +189 -0
- package/dist/src/matchmaking/seeker-walk-client.js.map +1 -0
- package/dist/src/matchmaking/traffic-validation.d.ts +128 -0
- package/dist/src/matchmaking/traffic-validation.d.ts.map +1 -0
- package/dist/src/matchmaking/traffic-validation.js +105 -0
- package/dist/src/matchmaking/traffic-validation.js.map +1 -0
- package/dist/src/network/network-manager-service.d.ts +4 -2
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +9 -3
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/protocol-client.d.ts +13 -0
- package/dist/src/protocol-client.d.ts.map +1 -1
- package/dist/src/protocol-client.js +74 -2
- package/dist/src/protocol-client.js.map +1 -1
- package/dist/src/reactivity/forwarder-host.d.ts +198 -0
- package/dist/src/reactivity/forwarder-host.d.ts.map +1 -0
- package/dist/src/reactivity/forwarder-host.js +355 -0
- package/dist/src/reactivity/forwarder-host.js.map +1 -0
- package/dist/src/reactivity/index.d.ts +19 -0
- package/dist/src/reactivity/index.d.ts.map +1 -0
- package/dist/src/reactivity/index.js +19 -0
- package/dist/src/reactivity/index.js.map +1 -0
- package/dist/src/reactivity/notify-transport.d.ts +70 -0
- package/dist/src/reactivity/notify-transport.d.ts.map +1 -0
- package/dist/src/reactivity/notify-transport.js +111 -0
- package/dist/src/reactivity/notify-transport.js.map +1 -0
- package/dist/src/reactivity/origination-manager.d.ts +110 -0
- package/dist/src/reactivity/origination-manager.d.ts.map +1 -0
- package/dist/src/reactivity/origination-manager.js +129 -0
- package/dist/src/reactivity/origination-manager.js.map +1 -0
- package/dist/src/reactivity/protocols.d.ts +43 -0
- package/dist/src/reactivity/protocols.d.ts.map +1 -0
- package/dist/src/reactivity/protocols.js +50 -0
- package/dist/src/reactivity/protocols.js.map +1 -0
- package/dist/src/reactivity/push-state-gossip.d.ts +132 -0
- package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -0
- package/dist/src/reactivity/push-state-gossip.js +231 -0
- package/dist/src/reactivity/push-state-gossip.js.map +1 -0
- package/dist/src/reactivity/recover-transport.d.ts +167 -0
- package/dist/src/reactivity/recover-transport.d.ts.map +1 -0
- package/dist/src/reactivity/recover-transport.js +308 -0
- package/dist/src/reactivity/recover-transport.js.map +1 -0
- package/dist/src/reactivity/rotation-rereg-scheduler.d.ts +142 -0
- package/dist/src/reactivity/rotation-rereg-scheduler.d.ts.map +1 -0
- package/dist/src/reactivity/rotation-rereg-scheduler.js +223 -0
- package/dist/src/reactivity/rotation-rereg-scheduler.js.map +1 -0
- package/dist/src/reactivity/subscriber-registry.d.ts +47 -0
- package/dist/src/reactivity/subscriber-registry.d.ts.map +1 -0
- package/dist/src/reactivity/subscriber-registry.js +87 -0
- package/dist/src/reactivity/subscriber-registry.js.map +1 -0
- package/dist/src/reactivity/subscription-manager.d.ts +221 -0
- package/dist/src/reactivity/subscription-manager.d.ts.map +1 -0
- package/dist/src/reactivity/subscription-manager.js +302 -0
- package/dist/src/reactivity/subscription-manager.js.map +1 -0
- package/dist/src/reactivity/topic-bytes.d.ts +32 -0
- package/dist/src/reactivity/topic-bytes.d.ts.map +1 -0
- package/dist/src/reactivity/topic-bytes.js +34 -0
- package/dist/src/reactivity/topic-bytes.js.map +1 -0
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +51 -23
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/service.d.ts +46 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +79 -45
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/rpc-deadline.d.ts +37 -0
- package/dist/src/rpc-deadline.d.ts.map +1 -0
- package/dist/src/rpc-deadline.js +31 -0
- package/dist/src/rpc-deadline.js.map +1 -0
- package/dist/src/storage/block-storage.d.ts +2 -0
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +95 -2
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +25 -0
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +53 -2
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +193 -9
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/sync/client.d.ts +4 -1
- package/dist/src/sync/client.d.ts.map +1 -1
- package/dist/src/sync/client.js +5 -2
- package/dist/src/sync/client.js.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts +219 -0
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -0
- package/dist/src/testing/cohort-topic-mesh-harness.js +467 -0
- package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -0
- package/dist/src/testing/matchmaking-mesh-harness.d.ts +187 -0
- package/dist/src/testing/matchmaking-mesh-harness.d.ts.map +1 -0
- package/dist/src/testing/matchmaking-mesh-harness.js +358 -0
- package/dist/src/testing/matchmaking-mesh-harness.js.map +1 -0
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +23 -1
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/dist/src/testing/reactivity-mesh-harness.d.ts +298 -0
- package/dist/src/testing/reactivity-mesh-harness.d.ts.map +1 -0
- package/dist/src/testing/reactivity-mesh-harness.js +663 -0
- package/dist/src/testing/reactivity-mesh-harness.js.map +1 -0
- package/package.json +7 -4
- package/src/cluster/block-transfer-service.ts +133 -68
- package/src/cluster/client.ts +64 -45
- package/src/cluster/cluster-error.ts +64 -0
- package/src/cluster/cluster-repo.ts +405 -55
- package/src/cluster/commit-cert.ts +139 -0
- package/src/cluster/service.ts +116 -44
- package/src/cluster/spread-on-churn.ts +328 -285
- package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -0
- package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -0
- package/src/cohort-topic/bootstrap-parent-reference.ts +159 -0
- package/src/cohort-topic/change-bridge.ts +109 -0
- package/src/cohort-topic/cohort-gossip-driver.ts +144 -0
- package/src/cohort-topic/cohort-gossip-transport.ts +74 -0
- package/src/cohort-topic/fret-trust-anchor.ts +153 -0
- package/src/cohort-topic/host.ts +2175 -0
- package/src/cohort-topic/index.ts +13 -0
- package/src/cohort-topic/membership-publish-sink.ts +20 -0
- package/src/cohort-topic/membership-source.ts +68 -0
- package/src/cohort-topic/peer-codec.ts +31 -0
- package/src/cohort-topic/peer-sig.ts +86 -0
- package/src/cohort-topic/protocols.ts +71 -0
- package/src/cohort-topic/reactivity-membership-gate.ts +77 -0
- package/src/cohort-topic/size-estimator.ts +16 -0
- package/src/cohort-topic/stream-util.ts +87 -0
- package/src/cohort-topic/threshold-crypto.ts +239 -0
- package/src/cohort-topic/topic-router.ts +77 -0
- package/src/dispute/cascade.ts +517 -0
- package/src/dispute/client.ts +12 -2
- package/src/dispute/dispute-service.ts +171 -24
- package/src/dispute/index.ts +42 -0
- package/src/dispute/invalidation.ts +587 -0
- package/src/dispute/types.ts +14 -0
- package/src/index.ts +4 -0
- package/src/libp2p-key-network.ts +49 -6
- package/src/libp2p-node-base.ts +620 -12
- package/src/matchmaking/aggregate-counts.ts +104 -0
- package/src/matchmaking/index.ts +20 -0
- package/src/matchmaking/module.ts +363 -0
- package/src/matchmaking/protocols.ts +51 -0
- package/src/matchmaking/provider-manager.ts +95 -0
- package/src/matchmaking/query-handler.ts +88 -0
- package/src/matchmaking/query-transport.ts +483 -0
- package/src/matchmaking/seeker-manager.ts +64 -0
- package/src/matchmaking/seeker-walk-client.ts +293 -0
- package/src/matchmaking/traffic-validation.ts +195 -0
- package/src/network/network-manager-service.ts +9 -2
- package/src/protocol-client.ts +64 -3
- package/src/reactivity/forwarder-host.ts +438 -0
- package/src/reactivity/index.ts +19 -0
- package/src/reactivity/notify-transport.ts +144 -0
- package/src/reactivity/origination-manager.ts +192 -0
- package/src/reactivity/protocols.ts +61 -0
- package/src/reactivity/push-state-gossip.ts +291 -0
- package/src/reactivity/recover-transport.ts +408 -0
- package/src/reactivity/rotation-rereg-scheduler.ts +256 -0
- package/src/reactivity/subscriber-registry.ts +96 -0
- package/src/reactivity/subscription-manager.ts +450 -0
- package/src/reactivity/topic-bytes.ts +37 -0
- package/src/repo/client.ts +54 -23
- package/src/repo/service.ts +90 -42
- package/src/rpc-deadline.ts +45 -0
- package/src/storage/block-storage.ts +106 -3
- package/src/storage/i-block-storage.ts +27 -0
- package/src/storage/storage-repo.ts +209 -13
- package/src/sync/client.ts +5 -2
- package/src/testing/cohort-topic-mesh-harness.ts +613 -0
- package/src/testing/matchmaking-mesh-harness.ts +475 -0
- package/src/testing/mesh-harness.ts +22 -2
- package/src/testing/reactivity-mesh-harness.ts +922 -0
|
@@ -0,0 +1,2175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic FRET host (`docs/cohort-topic.md` §FRET integration L768-803).
|
|
3
|
+
*
|
|
4
|
+
* Composes the db-core substrate (participant-facing {@link CohortTopicService} + cohort-side
|
|
5
|
+
* {@link CohortMemberEngine}) over the FRET + libp2p ports and runs it as a service on one node:
|
|
6
|
+
*
|
|
7
|
+
* - registers the four `/optimystic/cohort-topic/1.0.0/*` protocols on the libp2p node;
|
|
8
|
+
* - sets FRET's activity handler so a `RouteAndMaybeAct`-routed `RegisterV1` runs the cohort decision;
|
|
9
|
+
* - binds the db-core ports to the FRET-backed adapters (router, gossip, membership, size estimator).
|
|
10
|
+
*
|
|
11
|
+
* **Per-served-coord scoping.** A topic's responsible cohort sits at `coord_0(_, topicId)` (and at
|
|
12
|
+
* `coord_d(P, topicId)` for `d ≥ 1`), which is unrelated to any node's own ring position. A node
|
|
13
|
+
* genuinely belongs to *many* cohorts — one per coord FRET routes to it — so this host keeps a lazy
|
|
14
|
+
* {@link CoordRegistry}: a `Map<servedCoord, CoordEngine>` where each {@link CoordEngine} owns the
|
|
15
|
+
* per-coord slice of state (its own store, gossip bus, willingness/traffic/renewal/cold-start, a
|
|
16
|
+
* {@link PromotionLifecycle} with coord-derived tier inputs, a {@link CohortMemberEngine}, its own
|
|
17
|
+
* {@link MembershipCertPublisher}, and its own real `k − x` threshold signer) and threshold-signs with
|
|
18
|
+
* the FRET cohort *around the served coord*. The node-wide collaborators (`hash`, `slots`, `barometer`,
|
|
19
|
+
* the FRET ports, the participant-facing service, and the verify-only verifier signer) stay singletons
|
|
20
|
+
* and are injected into each engine. The host recomputes the served coord from each decoded `RegisterV1`
|
|
21
|
+
* (`addressing.coord(treeTier, participantCoord, topicId)`), so both the activity callback and the direct
|
|
22
|
+
* `register` protocol handler dispatch to the right cohort.
|
|
23
|
+
*
|
|
24
|
+
* **Real threshold signatures.** Each coord engine assembles a genuine `k − x` cohort signature
|
|
25
|
+
* (a collected per-member Ed25519 multisig) over the new `/sign` protocol — see
|
|
26
|
+
* {@link FretCohortThresholdCrypto} — and drives a {@link MembershipCertPublisher}, so a remote
|
|
27
|
+
* `MembershipVerifier` can verify the served `MembershipCertV1` for real. Assembly needs the node's
|
|
28
|
+
* `options.privateKey`; key-less hosts compose but cannot threshold-sign (the publisher/promotion paths
|
|
29
|
+
* are simply not driven). The periodic membership refresh is exposed as the per-engine
|
|
30
|
+
* {@link CoordEngine.pumpMembership} / {@link CoordEngine.onStabilized} hooks for the gossip-cadence
|
|
31
|
+
* driver to call.
|
|
32
|
+
*
|
|
33
|
+
* **Anti-DoS + cold-start (gaps 6–7).** Each {@link CoordEngine} is injected its own per-coord anti-DoS
|
|
34
|
+
* guards — a `RegisterRateLimiter` (4/min per peer-topic), a `CorrelationReplayGuard` (60 s freshness),
|
|
35
|
+
* and a `TopicBudget` (2048 topics, LRU) — so a budget/limit at one coord is independent of another. The
|
|
36
|
+
* node-level {@link BootstrapEvidence} policy (one tier→verifier policy, no per-coord state) is built once
|
|
37
|
+
* and shared. db-core embeds no PoW / reputation scheme, so the host supplies the real verifiers
|
|
38
|
+
* ({@link createPoWVerifier} / {@link createReputationVerifier}) and the participant-side PoW minter
|
|
39
|
+
* ({@link createBootstrapEvidenceBuilder}): once configured, a node gates cold-root `bootstrap: true` at
|
|
40
|
+
* T2/T3 (PoW, or a referee reputation endorsement when offered, or a signed parent reference) and at T0/T1
|
|
41
|
+
* once a committed-existence backing is wired (a signed parent reference); a configured node with no such
|
|
42
|
+
* backing keeps T0/T1 permissive-but-logged so cold-root origination is not blocked
|
|
43
|
+
* (`cohort-topic-bootstrap-coldstart-origination-regression`), and an entirely unconfigured host stays
|
|
44
|
+
* permissive-but-logged at every tier (never an undefined gate). A cold-started tier-`d > 0` forwarder registers with
|
|
45
|
+
* its tier-`(d − 1)` parent by routing a forwarder-link frame over the router (gap 7), staying
|
|
46
|
+
* `awaiting_parent` until the ack.
|
|
47
|
+
*
|
|
48
|
+
* **Scope.** `followOn` derivation for a promoted-redirect arrival is parked in backlog
|
|
49
|
+
* (`cohort-topic-followon-derivation`); this milestone serves a **single tier-0 cohort**, so `followOn`
|
|
50
|
+
* stays `false` and tier-0 bootstrap instantiation goes through the `bootstrap: true` path. The
|
|
51
|
+
* behavioral substrate is validated at mock-tier by `test/cohort-topic/service.spec.ts` and end-to-end
|
|
52
|
+
* across a real multi-node cohort (real Ed25519 keys, mock transport routing the five protocols + FRET
|
|
53
|
+
* `routeAct`/`assembleCohort`) by `test/cohort-topic/live-tier.spec.ts`.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import type { Libp2p } from "libp2p";
|
|
57
|
+
import type { Connection, PeerId, PrivateKey, Stream } from "@libp2p/interface";
|
|
58
|
+
import type { FretService } from "p2p-fret";
|
|
59
|
+
import { hashPeerId, readAllBounded } from "p2p-fret";
|
|
60
|
+
import {
|
|
61
|
+
RingHash,
|
|
62
|
+
createRegistrationStore,
|
|
63
|
+
createSlotAssigner,
|
|
64
|
+
createCohortGossipBus,
|
|
65
|
+
createWillingnessCheck,
|
|
66
|
+
createPromotionLifecycle,
|
|
67
|
+
createColdStartManager,
|
|
68
|
+
createTrafficCounters,
|
|
69
|
+
createRenewalCohortSide,
|
|
70
|
+
createMembershipVerifier,
|
|
71
|
+
createMembershipSourceRouter,
|
|
72
|
+
createMembershipCertPublisher,
|
|
73
|
+
createCohortSigner,
|
|
74
|
+
createCohortMemberEngine,
|
|
75
|
+
createCohortTopicService,
|
|
76
|
+
createLoadBarometer,
|
|
77
|
+
createTierAddressing,
|
|
78
|
+
createRegisterRateLimiter,
|
|
79
|
+
createCorrelationReplayGuard,
|
|
80
|
+
createTopicBudget,
|
|
81
|
+
createBootstrapEvidence,
|
|
82
|
+
LruMap,
|
|
83
|
+
coreProfile,
|
|
84
|
+
DEFAULT_MIN_SIGS,
|
|
85
|
+
DEFAULT_MAX_NO_POW_TIER,
|
|
86
|
+
DEFAULT_TRAFFIC_WINDOW_SECONDS,
|
|
87
|
+
DEFAULT_TTL_MS,
|
|
88
|
+
bytesToB64url,
|
|
89
|
+
b64urlToBytes,
|
|
90
|
+
bytesEqual,
|
|
91
|
+
compareBytes,
|
|
92
|
+
encodeCohortMessage,
|
|
93
|
+
decodeCohortMessage,
|
|
94
|
+
membershipCertSignable,
|
|
95
|
+
membershipCertSigningPayload,
|
|
96
|
+
toCohortTopicSummary,
|
|
97
|
+
validateRegisterV1,
|
|
98
|
+
validateRenewV1,
|
|
99
|
+
validateSignRequestV1,
|
|
100
|
+
validateSignReplyV1,
|
|
101
|
+
validatePromotionNoticeV1,
|
|
102
|
+
validateDemotionNoticeV1,
|
|
103
|
+
registerSigningPayload,
|
|
104
|
+
renewSigningPayload,
|
|
105
|
+
cohortGossipSigningPayload,
|
|
106
|
+
promotionNoticeSigningPayload,
|
|
107
|
+
demotionNoticeSigningPayload,
|
|
108
|
+
type BootstrapEvidence,
|
|
109
|
+
type BootstrapEvidenceDeps,
|
|
110
|
+
type CohortGossipV1,
|
|
111
|
+
type CohortGossipSignable,
|
|
112
|
+
type CohortTopicService,
|
|
113
|
+
type CohortMemberEngine,
|
|
114
|
+
type CohortSnapshot,
|
|
115
|
+
type CohortSnapshotView,
|
|
116
|
+
type CohortSigner,
|
|
117
|
+
type CohortView,
|
|
118
|
+
type CorrelationReplayGuardConfig,
|
|
119
|
+
type DemotionNoticeV1,
|
|
120
|
+
type Forwarder,
|
|
121
|
+
type ITopicRouter,
|
|
122
|
+
type MembershipCertPublisher,
|
|
123
|
+
type MembershipCertV1,
|
|
124
|
+
type MembershipVerifier,
|
|
125
|
+
type NodeProfile,
|
|
126
|
+
type ParticipantSigner,
|
|
127
|
+
type PromotionConfig,
|
|
128
|
+
type PromotionNoticeV1,
|
|
129
|
+
type RegisterRateLimiter,
|
|
130
|
+
type RegisterRateLimiterConfig,
|
|
131
|
+
type RegisterReplyV1,
|
|
132
|
+
type RegisterV1,
|
|
133
|
+
type RegistrationRecord,
|
|
134
|
+
type RenewReplyV1,
|
|
135
|
+
type RenewV1,
|
|
136
|
+
type RingCoord,
|
|
137
|
+
type RotationAttestation,
|
|
138
|
+
type SignKind,
|
|
139
|
+
type SignReplyV1,
|
|
140
|
+
type SignRequestV1,
|
|
141
|
+
type Tier,
|
|
142
|
+
type TopicBudgetConfig,
|
|
143
|
+
type TopicTrafficV1,
|
|
144
|
+
type IMembershipSource,
|
|
145
|
+
type TrustRoot,
|
|
146
|
+
} from "@optimystic/db-core";
|
|
147
|
+
import { randomBytes } from "@libp2p/crypto";
|
|
148
|
+
import { peerIdFromString } from "@libp2p/peer-id";
|
|
149
|
+
import { FretTopicRouter } from "./topic-router.js";
|
|
150
|
+
import { FretCohortGossipTransport, type CohortPeerResolver } from "./cohort-gossip-transport.js";
|
|
151
|
+
import { buildCohortGossip, createPendingDeltas, DEFAULT_GOSSIP_INTERVAL_MS } from "./cohort-gossip-driver.js";
|
|
152
|
+
import { FretMembershipSource } from "./membership-source.js";
|
|
153
|
+
import { FretMembershipPublishSink } from "./membership-publish-sink.js";
|
|
154
|
+
import { FretCohortThresholdCrypto, createVerifyOnlyThresholdCrypto } from "./threshold-crypto.js";
|
|
155
|
+
import { FretTrustAnchor } from "./fret-trust-anchor.js";
|
|
156
|
+
import { FretSizeEstimator } from "./size-estimator.js";
|
|
157
|
+
import { peerIdToBytes, bytesToPeerIdString } from "./peer-codec.js";
|
|
158
|
+
import { signPeer, verifyPeerSig } from "./peer-sig.js";
|
|
159
|
+
import { createPoWVerifier, createReputationVerifier, type BootstrapReputationView } from "./bootstrap-evidence-verifiers.js";
|
|
160
|
+
import { createParentReferenceVerifier, createDefaultParentTopicView, type BootstrapParentTopicView } from "./bootstrap-parent-reference.js";
|
|
161
|
+
import { createBootstrapEvidenceBuilder } from "./bootstrap-evidence-builder.js";
|
|
162
|
+
import { DEFAULT_COHORT_TOPIC_PROTOCOLS, cohortTopicProtocolList, type CohortTopicProtocols } from "./protocols.js";
|
|
163
|
+
import { requestResponse, DEFAULT_STREAM_MAX_BYTES } from "./stream-util.js";
|
|
164
|
+
import { createLogger } from "../logger.js";
|
|
165
|
+
|
|
166
|
+
const log = createLogger("cohort-topic");
|
|
167
|
+
|
|
168
|
+
export interface CohortTopicHostOptions {
|
|
169
|
+
/** Per-node tier profile. Default {@link coreProfile}. */
|
|
170
|
+
readonly profile?: NodeProfile;
|
|
171
|
+
/** Network name (namespaces the four protocol IDs); default uses the canonical IDs. */
|
|
172
|
+
readonly protocols?: CohortTopicProtocols;
|
|
173
|
+
/** Requested cohort size `wantK`. Default 16. */
|
|
174
|
+
readonly wantK?: number;
|
|
175
|
+
/** Threshold signers `minSigs = k − x`. Default {@link DEFAULT_MIN_SIGS}. */
|
|
176
|
+
readonly minSigs?: number;
|
|
177
|
+
/** Fan-out per tier `F`. Default 16. */
|
|
178
|
+
readonly fanout?: number;
|
|
179
|
+
/** Per-frame ceiling. Default {@link DEFAULT_STREAM_MAX_BYTES}. */
|
|
180
|
+
readonly maxBytes?: number;
|
|
181
|
+
/**
|
|
182
|
+
* Gossip-round cadence in ms (the periodic driver tick). Default {@link DEFAULT_GOSSIP_INTERVAL_MS}
|
|
183
|
+
* (~one round). Each tick drives every live {@link CoordEngine}'s gossip broadcast, TTL sweep,
|
|
184
|
+
* membership-cert refresh, and demotion check; the refresh (5 min) and demotion hysteresis (5 min)
|
|
185
|
+
* self-gate on elapsed time, so a fast tick is safe.
|
|
186
|
+
*/
|
|
187
|
+
readonly gossipIntervalMs?: number;
|
|
188
|
+
/**
|
|
189
|
+
* The node's libp2p Ed25519 private key. Required for the live participant signer: register/renew
|
|
190
|
+
* bodies are peer-key-signed over their canonical image, and inbound register/`reattach` signatures
|
|
191
|
+
* are verified against the participant's claimed peer id. libp2p does not expose the key off
|
|
192
|
+
* `node.peerId`, so it is threaded explicitly (mirrors `clusterMember` / `DisputeService`, sourced
|
|
193
|
+
* from `options.privateKey ?? generateKeyPair('Ed25519')` in `libp2p-node-base.ts`). When omitted,
|
|
194
|
+
* the host falls back to the interim empty-string signer (one-time warn) and does **not** enforce
|
|
195
|
+
* inbound participant-signature verification, so unit tests compose without a key.
|
|
196
|
+
*/
|
|
197
|
+
readonly privateKey?: PrivateKey;
|
|
198
|
+
/**
|
|
199
|
+
* Anti-DoS guard wiring (gap 6). The per-{@link CoordEngine} guards (rate limiter, replay guard,
|
|
200
|
+
* topic budget) and the node-level bootstrap-evidence policy are always constructed with documented
|
|
201
|
+
* defaults; this lets a caller (or a test) tune them. Omit for production defaults.
|
|
202
|
+
*/
|
|
203
|
+
readonly antiDos?: CohortTopicAntiDosOptions;
|
|
204
|
+
/**
|
|
205
|
+
* Promotion / demotion lifecycle tuning (`cap_promote`, hysteresis windows, slope lookahead) applied
|
|
206
|
+
* to every {@link CoordEngine}'s {@link PromotionLifecycle}. Omit for the production defaults
|
|
207
|
+
* (`cap_promote = 64`, …); supply a lowered `capPromote` to drive promotion with a small participant
|
|
208
|
+
* count (the live-tier e2e). The single-cohort milestone never overrides `childCohortCount` / `treeTier`
|
|
209
|
+
* — those stay coord-derived — so only the count/load thresholds are tunable here.
|
|
210
|
+
*/
|
|
211
|
+
readonly promotion?: PromotionConfig;
|
|
212
|
+
/**
|
|
213
|
+
* Optional committed-state existence reader backing the **committed-tier (T0/T1)** parent-reference
|
|
214
|
+
* bootstrap-evidence check (`cohort-topic-bootstrap-parent-reference`). Given `coord_0(parentTopicId)`,
|
|
215
|
+
* returns whether the node locally knows the parent topic's committed cohort exists. Threaded from
|
|
216
|
+
* node-base when a coord-keyed committed-membership index is available; when omitted, T0/T1 parent-ref
|
|
217
|
+
* existence fails closed (a FRET-cached cert must not vouch for committed-tier existence — committed-tier
|
|
218
|
+
* integrity) while T2/T3 parent-ref still consults the FRET membership cache. See
|
|
219
|
+
* {@link createDefaultParentTopicView}.
|
|
220
|
+
*/
|
|
221
|
+
readonly committedParentTopicReader?: (coord: RingCoord) => boolean;
|
|
222
|
+
/**
|
|
223
|
+
* The participant's initial trust roots: the genesis-block-related cohorts, validated against the
|
|
224
|
+
* genesis block hash **out-of-band by the caller before seeding** (`docs/cohort-topic.md`
|
|
225
|
+
* §Bootstrapping trust). They are the base case of every attestation chain — a cert matching a root by
|
|
226
|
+
* `(coord, epoch, member-set)` is trusted directly, ahead of the FRET-ring direct anchor — so a forged
|
|
227
|
+
* cert for a genesis cohort is rejected even before the ring is consulted. Threaded straight into
|
|
228
|
+
* `createMembershipVerifier({ trustRoots })`.
|
|
229
|
+
*
|
|
230
|
+
* **Network-config, empty by default.** The concrete genesis-cohort set is a property of a specific
|
|
231
|
+
* network's genesis block; this is the typed seam, not a fabricated value. Omitted (the default `[]`)
|
|
232
|
+
* the verifier behaves exactly as with no roots — the chain bottoms out at the direct anchor / TOFU, so
|
|
233
|
+
* a network with no genesis cohort configured is never broken. Do **not** seed fake roots here.
|
|
234
|
+
*/
|
|
235
|
+
readonly genesisTrustRoots?: readonly TrustRoot[];
|
|
236
|
+
/**
|
|
237
|
+
* Wall clock (ms) the `/sign` membership endorser uses for its `stabilizedAt` far-future sanity bound
|
|
238
|
+
* (a member refuses to co-sign a cert whose `stabilizedAt` is more than a few seconds ahead of its own
|
|
239
|
+
* clock — `cohort-topic-sign-endorsement-payload-binding`). Defaults to {@link Date.now}, the right
|
|
240
|
+
* choice for a production node. A **virtual-time** test harness (which drives publish `stabilizedAt`
|
|
241
|
+
* from arbitrary explicit timestamps rather than wall clock) injects a clock that does not trip the
|
|
242
|
+
* bound — e.g. `() => Number.POSITIVE_INFINITY` — so its synthetic future timestamps are not rejected.
|
|
243
|
+
*/
|
|
244
|
+
readonly now?: () => number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The reputation-view shape the bootstrap-evidence referee verifier consults — `{ isBanned, getScore }`
|
|
248
|
+
// (a subset of `IPeerReputation`; `PeerReputationService` satisfies it directly). Defined with the
|
|
249
|
+
// verifier it backs and re-exported here so existing importers keep resolving it off the host module.
|
|
250
|
+
export type { BootstrapReputationView } from "./bootstrap-evidence-verifiers.js";
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Anti-DoS wiring overrides for a {@link CohortTopicHost} (`docs/cohort-topic.md` §Anti-DoS).
|
|
254
|
+
*
|
|
255
|
+
* The rate limiter, replay guard, and topic budget are **per-served-coord** (they key on
|
|
256
|
+
* `(peer, topic)` / per-cohort topic state, which is coord-scoped) — one set is built per
|
|
257
|
+
* {@link CoordEngine} from these configs. The bootstrap-evidence policy is **node-level** (a
|
|
258
|
+
* tier→verifier policy with no per-coord state) and is shared by every engine.
|
|
259
|
+
*/
|
|
260
|
+
export interface CohortTopicAntiDosOptions {
|
|
261
|
+
/** Per-`(peer, topic)` register rate limiter. Default `register_rate_per_peer = 4 / 60 s`, exponential back-off. */
|
|
262
|
+
readonly rateLimiter?: RegisterRateLimiterConfig;
|
|
263
|
+
/** Correlation-id freshness + replay guard. Default `maxAge = 60 s`, `futureSkew = 5 s`. */
|
|
264
|
+
readonly replayGuard?: CorrelationReplayGuardConfig;
|
|
265
|
+
/** Per-cohort forwarder-state budget. Default `topics_max = 2048`, LRU by participant count. */
|
|
266
|
+
readonly topicBudget?: TopicBudgetConfig;
|
|
267
|
+
/**
|
|
268
|
+
* Bootstrap-evidence verifiers for cold-root instantiation. db-core embeds no PoW / reputation /
|
|
269
|
+
* committed-work scheme; inject the real checks here. Any verifier supplied wins over the defaults
|
|
270
|
+
* below; the gate is **never** left undefined (an unset gate means cold-root bootstrap is
|
|
271
|
+
* unauthenticated — `docs/cohort-topic.md` §Anti-DoS).
|
|
272
|
+
*/
|
|
273
|
+
readonly bootstrapEvidence?: BootstrapEvidenceDeps;
|
|
274
|
+
/**
|
|
275
|
+
* Optional peer-reputation view backing the **reputation-endorsement** evidence path: a *referee*
|
|
276
|
+
* peer-key-signs the bootstrap bound image, and the cohort admits it iff the signature verifies and
|
|
277
|
+
* the referee is sufficiently reputable here (not banned **and** below the deprioritize threshold).
|
|
278
|
+
* `PeerReputationService` satisfies this `{ isBanned, getScore }` shape directly. Supplying it (or any
|
|
279
|
+
* `bootstrapEvidence` override) makes the gate **configured**: the real PoW verifier runs, the referee
|
|
280
|
+
* verifier backs the reputation path, the real signed-parent-reference verifier backs the parent-ref path
|
|
281
|
+
* (permissive-but-logged only at T0/T1 when this host has no committed-existence backing — see
|
|
282
|
+
* {@link createBootstrapEvidencePolicy}), and any otherwise-unfilled verifier fails closed — so a
|
|
283
|
+
* banned/low-rep referee cannot slip the T2/T3 `PoW || reputation || parent-ref` disjunction. When omitted
|
|
284
|
+
* (and no `bootstrapEvidence` verifier is supplied), the gate is permissive-but-logged (the
|
|
285
|
+
* entirely-unconfigured interim node).
|
|
286
|
+
*/
|
|
287
|
+
readonly reputation?: BootstrapReputationView;
|
|
288
|
+
/**
|
|
289
|
+
* Proof-of-work difficulty (leading zero bits) the real PoW verifier requires. Default the db-core
|
|
290
|
+
* `DEFAULT_POW_DIFFICULTY_BITS`. A low value (e.g. `0`) keeps minting fast/deterministic in tests.
|
|
291
|
+
* Applies to both the cohort-side PoW verifier and this node's participant-side PoW builder.
|
|
292
|
+
*/
|
|
293
|
+
readonly powDifficultyBits?: number;
|
|
294
|
+
/**
|
|
295
|
+
* Strict "sufficient reputation" cutoff for a bootstrap referee — a referee with `getScore < this` is
|
|
296
|
+
* accepted. Default the reputation service's `deprioritize` threshold.
|
|
297
|
+
*/
|
|
298
|
+
readonly deprioritizeThreshold?: number;
|
|
299
|
+
/**
|
|
300
|
+
* Existence view backing the **signed parent-reference** evidence path (`verifyParentReference`). A test
|
|
301
|
+
* seam: when supplied it overrides *which* existence view the real verifier consults (the host default is
|
|
302
|
+
* built from the FRET membership cache + addressing + the optional
|
|
303
|
+
* {@link CohortTopicHostOptions.committedParentTopicReader}). It does **not** by itself make the gate
|
|
304
|
+
* "configured" — only a `reputation` view or a `bootstrapEvidence` override flips that (see
|
|
305
|
+
* {@link createBootstrapEvidencePolicy}); an unconfigured host stays permissive and never consults this
|
|
306
|
+
* view. Pair it with `reputation`/`bootstrapEvidence` to exercise the real parent-reference verifier. See
|
|
307
|
+
* {@link createDefaultParentTopicView} / {@link createParentReferenceVerifier}.
|
|
308
|
+
*/
|
|
309
|
+
readonly parentTopicView?: BootstrapParentTopicView;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* One cohort the node serves, bound to a FRET-routed coordinate. Owns the per-coord slice of cohort
|
|
314
|
+
* state; the node-wide collaborators are injected (see {@link CoordEngineContext}).
|
|
315
|
+
*/
|
|
316
|
+
export interface CoordEngine {
|
|
317
|
+
/** The served ring coordinate this engine is the cohort for. */
|
|
318
|
+
readonly servedCoord: RingCoord;
|
|
319
|
+
/** Tree tier `d` the served coord was instantiated at (fixed by the first register). */
|
|
320
|
+
readonly treeTier: number;
|
|
321
|
+
/** Cohort-side register/renew/sweep engine driven by the protocol handlers + activity callback. */
|
|
322
|
+
readonly engine: CohortMemberEngine;
|
|
323
|
+
/** The FRET-assembled cohort around {@link servedCoord} (self prepended, deduped) + epoch. */
|
|
324
|
+
cohort(): CohortSnapshotView;
|
|
325
|
+
/**
|
|
326
|
+
* The cohort member peer-id strings this engine served under `epoch` (the **current** or the
|
|
327
|
+
* immediately-**prior** observed epoch), or `undefined` if it tracked no such epoch. Drives the `/sign`
|
|
328
|
+
* `"rotation"` endorsement gate: a member endorses a hand-off from `prevEpoch` only when it was a member
|
|
329
|
+
* of the cohort at that epoch. Returns `undefined` past the two-deep history (a rapid double rotation —
|
|
330
|
+
* the requester then re-anchors). See `cohort-topic-trust-anchor-rotation-production`.
|
|
331
|
+
*/
|
|
332
|
+
cohortIdentityAt(epoch: Uint8Array): readonly string[] | undefined;
|
|
333
|
+
/** True iff this engine currently holds any registration record (a cold probe leaves it empty). */
|
|
334
|
+
hasState(): boolean;
|
|
335
|
+
/** True iff this engine holds the record for `(topicId, participantId)` — the renewal lookup key. */
|
|
336
|
+
holds(topicId: Uint8Array, participantId: Uint8Array): boolean;
|
|
337
|
+
/**
|
|
338
|
+
* This cohort's locally-known direct registration records for `topicId` (the cohort-side read the
|
|
339
|
+
* matchmaking `QueryV1` handler / aggregate-count producer serve from — `docs/matchmaking.md`
|
|
340
|
+
* §Seeker query). A renewed record is live; a TTL-swept one is gone after the next `gossipRound`.
|
|
341
|
+
*/
|
|
342
|
+
records(topicId: Uint8Array): readonly RegistrationRecord[];
|
|
343
|
+
/**
|
|
344
|
+
* This cohort's current gossip-derived traffic barometer for `topicId` (own last-published counts +
|
|
345
|
+
* siblings' last-gossiped summaries + `directParticipants` from the store). The matchmaking `QueryV1`
|
|
346
|
+
* reply attaches it and the seeker hang-out decision consumes it (`docs/matchmaking.md` §Hang-out vs.
|
|
347
|
+
* continue). Non-mutating — a synchronous read over the same in-memory state a `gossipRound` mutates,
|
|
348
|
+
* lagging at most one gossip round (it reflects the last frozen summaries, never raw mid-round counts).
|
|
349
|
+
*/
|
|
350
|
+
topicTraffic(topicId: Uint8Array): TopicTrafficV1;
|
|
351
|
+
/**
|
|
352
|
+
* Publish a fresh threshold-signed `MembershipCertV1` on a cohort-membership-change / stabilization
|
|
353
|
+
* event (republishes only when the first `k − x` members changed). Returns the cert if published.
|
|
354
|
+
* Resolves `undefined` if no republish was needed or the quorum was unreachable this round.
|
|
355
|
+
*/
|
|
356
|
+
onStabilized(now: number): Promise<MembershipCertV1 | undefined>;
|
|
357
|
+
/**
|
|
358
|
+
* Periodic membership-cert refresh hook for the gossip-cadence driver to call (republishes once
|
|
359
|
+
* `T_membership_refresh` has elapsed). Returns the cert if (re)published, else `undefined`.
|
|
360
|
+
*/
|
|
361
|
+
pumpMembership(now: number): Promise<MembershipCertV1 | undefined>;
|
|
362
|
+
/**
|
|
363
|
+
* One gossip round: TTL-sweep stale records (firing the `evicted` deltas), freeze the per-topic
|
|
364
|
+
* traffic summaries, drain the accumulated record/eviction deltas, and broadcast a signed
|
|
365
|
+
* {@link CohortGossipV1} (willingness/load/traffic + deltas) to the cohort. Returns the broadcast
|
|
366
|
+
* frame, or `undefined` when the engine is idle (no resident topics and no deltas → nothing to send).
|
|
367
|
+
*/
|
|
368
|
+
gossipRound(now: number): Promise<CohortGossipV1 | undefined>;
|
|
369
|
+
/**
|
|
370
|
+
* Time-driven demotion check across this engine's resident topics; broadcasts any threshold-signed
|
|
371
|
+
* {@link DemotionNoticeV1} the lifecycle returns (root tier-0 cohorts never demote). No-op without a
|
|
372
|
+
* signing key (the verify-only per-coord signer cannot assemble a notice).
|
|
373
|
+
*/
|
|
374
|
+
demotionTick(now: number): Promise<void>;
|
|
375
|
+
/** The merged per-member gossip view (willingness / load / per-topic summaries) for this cohort. */
|
|
376
|
+
cohortView(): CohortView;
|
|
377
|
+
/** True iff this engine currently serves `topicId` (holds a record or a cold-start forwarder for it). */
|
|
378
|
+
servesTopic(topicId: Uint8Array): boolean;
|
|
379
|
+
/**
|
|
380
|
+
* Whether `topicId` currently occupies a slot in this coord's anti-DoS topic budget. A drained topic
|
|
381
|
+
* stays resident-but-cold (`{@link budgetParticipantCount} === 0`) until a new topic reuses its slot;
|
|
382
|
+
* after that reuse `budgetHasTopic` is `false`. Test/diagnostic introspection over the per-coord budget
|
|
383
|
+
* (distinct from {@link servesTopic}, which a never-demoted cold-start forwarder keeps `true`).
|
|
384
|
+
*/
|
|
385
|
+
budgetHasTopic(topicId: Uint8Array): boolean;
|
|
386
|
+
/**
|
|
387
|
+
* The direct-participant count the topic budget last recorded for a resident `topicId` (its LRU
|
|
388
|
+
* eviction key), or `undefined` when `topicId` holds no budget slot. A resident reporting `0` has
|
|
389
|
+
* drained and is the next coldest-evictable candidate. Test/diagnostic introspection.
|
|
390
|
+
*/
|
|
391
|
+
budgetParticipantCount(topicId: Uint8Array): number | undefined;
|
|
392
|
+
/**
|
|
393
|
+
* The cold-start {@link Forwarder} this engine instantiated for `topicId`, or `undefined` if none.
|
|
394
|
+
* Exposes the parent-link lifecycle (`awaiting_parent` → `serving`) for the cold-start wiring (gap 7).
|
|
395
|
+
*/
|
|
396
|
+
forwarder(topicId: Uint8Array): Forwarder | undefined;
|
|
397
|
+
/** Whether `topicId` is in promoted mode here — reflects both locally-originated and remotely-applied state. */
|
|
398
|
+
isPromoted(topicId: Uint8Array): boolean;
|
|
399
|
+
/** Adopt a verified promotion notice into this cohort's local state (see {@link NoticeApplyTarget}). */
|
|
400
|
+
applyPromotionNotice(notice: PromotionNoticeV1, now: number): void;
|
|
401
|
+
/** Adopt a verified demotion notice into this cohort's local state (see {@link NoticeApplyTarget}). */
|
|
402
|
+
applyDemotionNotice(notice: DemotionNoticeV1, now: number): void;
|
|
403
|
+
/** Tear down the per-coord gossip subscription. */
|
|
404
|
+
close(): void;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Lazy `servedCoord → CoordEngine` registry: one engine per coord FRET routes to this node. */
|
|
408
|
+
export interface CoordRegistry {
|
|
409
|
+
/**
|
|
410
|
+
* The {@link CoordEngine} for `coord`, creating + caching it on first touch. `treeTier` and
|
|
411
|
+
* `participantCoord` seed a freshly-created engine's coord-derived tier inputs (ignored if the
|
|
412
|
+
* engine already exists). Synchronous, so concurrent activity callbacks for the same coord share
|
|
413
|
+
* one engine without a second being constructed.
|
|
414
|
+
*/
|
|
415
|
+
forCoord(coord: RingCoord, treeTier: number, participantCoord: Uint8Array): CoordEngine;
|
|
416
|
+
/** The engine holding the record for `(topicId, participantId)`, or `undefined` (renewal dispatch). */
|
|
417
|
+
findHolder(topicId: Uint8Array, participantId: Uint8Array): CoordEngine | undefined;
|
|
418
|
+
/**
|
|
419
|
+
* The already-instantiated engine for `coord`, or `undefined` — a pure lookup that (unlike
|
|
420
|
+
* {@link forCoord}) never creates one. The `/sign` `"rotation"` endorsement gate uses it to consult this
|
|
421
|
+
* node's prior-epoch membership for the requested coord without spuriously instantiating a cohort it
|
|
422
|
+
* does not serve.
|
|
423
|
+
*/
|
|
424
|
+
findByCoord(coord: RingCoord): CoordEngine | undefined;
|
|
425
|
+
/**
|
|
426
|
+
* The engine serving `topicId` at `treeTier`, or `undefined` (inbound promote/demote notice dispatch).
|
|
427
|
+
* A served coord embeds `(tier, topic)`, so at most one engine matches — the cohort the notice's
|
|
428
|
+
* signers belong to. `undefined` means this node serves no such cohort (e.g. a demotion arriving at a
|
|
429
|
+
* parent that does not track the child), so the notice is dropped rather than applied.
|
|
430
|
+
*/
|
|
431
|
+
findServing(topicId: Uint8Array, treeTier: number): CoordEngine | undefined;
|
|
432
|
+
/** Every live engine (stop + sweep). */
|
|
433
|
+
all(): readonly CoordEngine[];
|
|
434
|
+
/** Close every engine's gossip subscription. */
|
|
435
|
+
close(): void;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** A running cohort-topic node: the participant service plus the per-coord cohort registry, on one FRET node. */
|
|
439
|
+
export interface CohortTopicHost {
|
|
440
|
+
/** Participant-facing substrate API (node scope, not a coord). */
|
|
441
|
+
readonly service: CohortTopicService;
|
|
442
|
+
/** Per-served-coord cohort engines (driven by the protocol handlers + activity callback). */
|
|
443
|
+
readonly registry: CoordRegistry;
|
|
444
|
+
/** The four registered protocol IDs. */
|
|
445
|
+
readonly protocols: CohortTopicProtocols;
|
|
446
|
+
/**
|
|
447
|
+
* The node's effective {@link NodeProfile} (Edge / Core). Exposed so a layered application (reactivity)
|
|
448
|
+
* can apply the same profile gate the host configured its engines with (e.g. Edge ⇒ subscriber-only).
|
|
449
|
+
*/
|
|
450
|
+
readonly profile: NodeProfile;
|
|
451
|
+
/**
|
|
452
|
+
* The intra-cohort gossip transport. Exposed so a layered application (reactivity push-state gossip) can
|
|
453
|
+
* ride its `broadcastOver` seam — the same one the promote-notice broadcast reuses — rather than standing
|
|
454
|
+
* up a second transport with duplicate cohort peer resolution.
|
|
455
|
+
*/
|
|
456
|
+
readonly gossipTransport: FretCohortGossipTransport;
|
|
457
|
+
/**
|
|
458
|
+
* The node-level `promote`-handler anti-abuse gate (per-`(peer, topic)` rate limiter + per-`(topic, tier)`
|
|
459
|
+
* `effectiveAt` high-water). Exposed for test/diagnostic introspection over its bounded-memory state — the
|
|
460
|
+
* limiter's `size` and the `highWater` `LruMap` — and so the gossip-cadence sweep wiring is observable.
|
|
461
|
+
*/
|
|
462
|
+
readonly promoteGate: PromoteGate;
|
|
463
|
+
/** Unregister the four protocols and tear down every coord engine. */
|
|
464
|
+
stop(): Promise<void>;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Node-wide collaborators injected into every {@link CoordEngine} (shared singletons). */
|
|
468
|
+
interface CoordEngineContext {
|
|
469
|
+
readonly hash: RingHash;
|
|
470
|
+
readonly addressing: ReturnType<typeof createTierAddressing>;
|
|
471
|
+
readonly slots: ReturnType<typeof createSlotAssigner>;
|
|
472
|
+
readonly barometer: ReturnType<typeof createLoadBarometer>;
|
|
473
|
+
readonly transport: FretCohortGossipTransport;
|
|
474
|
+
readonly profile: NodeProfile;
|
|
475
|
+
readonly selfMemberBytes: Uint8Array;
|
|
476
|
+
readonly wantK: number;
|
|
477
|
+
readonly minSigs: number;
|
|
478
|
+
readonly maxBytes: number;
|
|
479
|
+
/** Membership-cert sink the per-coord publisher serves through (node-wide; serves this node's cohort). */
|
|
480
|
+
readonly publishSink: FretMembershipPublishSink;
|
|
481
|
+
/**
|
|
482
|
+
* The node's libp2p key, threaded so each coord engine's threshold signer can add self's own chunk.
|
|
483
|
+
* Absent → key-less interim mode: the per-coord signer cannot assemble (the publisher/promotion paths
|
|
484
|
+
* are not driven in that mode), so threshold signing is unavailable until a key is supplied.
|
|
485
|
+
*/
|
|
486
|
+
readonly privateKey?: PrivateKey;
|
|
487
|
+
/** FRET-backed router; a coord engine routes its cold-start forwarder→parent link over it (gap 7). */
|
|
488
|
+
readonly router: ITopicRouter;
|
|
489
|
+
/** Per-coord anti-DoS guard configs (one guard set is built per {@link CoordEngine}). */
|
|
490
|
+
readonly antiDos: {
|
|
491
|
+
readonly rateLimiter?: RegisterRateLimiterConfig;
|
|
492
|
+
readonly replayGuard?: CorrelationReplayGuardConfig;
|
|
493
|
+
readonly topicBudget?: TopicBudgetConfig;
|
|
494
|
+
};
|
|
495
|
+
/** Node-level bootstrap-evidence policy, shared by every engine (no per-coord state). */
|
|
496
|
+
readonly bootstrapEvidence: BootstrapEvidence;
|
|
497
|
+
/** Promotion-lifecycle config applied to every engine (test seam for a lowered `cap_promote`). */
|
|
498
|
+
readonly promotionConfig?: PromotionConfig;
|
|
499
|
+
/** Dial a cohort member's `/sign` RPC (the threshold-assembly collection seam). */
|
|
500
|
+
readonly dialSign: (peerIdStr: string, request: SignRequestV1) => Promise<SignReplyV1>;
|
|
501
|
+
/** FRET two-sided assembly around `coord`, self prepended + deduped, with a deterministic epoch. */
|
|
502
|
+
readonly cohortAround: (coord: RingCoord) => CohortSnapshotView;
|
|
503
|
+
/** Verify an inbound `RegisterV1`'s participant peer-key signature (live-signer mode only). */
|
|
504
|
+
readonly verifyRegisterSig?: (reg: RegisterV1) => boolean;
|
|
505
|
+
/** Verify a privileged `RenewV1`'s participant peer-key signature — gates both the `reattach`
|
|
506
|
+
* promotion and the `withdraw` eviction (live-signer mode only). */
|
|
507
|
+
readonly verifyParticipantSig?: (renew: RenewV1) => boolean;
|
|
508
|
+
/**
|
|
509
|
+
* Sign an outbound `CohortGossipV1` envelope with the node peer key over its canonical image
|
|
510
|
+
* ({@link cohortGossipSigningPayload}). Live-signer mode only; absent → gossip ships unsigned (interim,
|
|
511
|
+
* matching the participant signer) and the receiver's {@link verifyGossip} gate is likewise absent.
|
|
512
|
+
*/
|
|
513
|
+
readonly signGossip?: (g: CohortGossipSignable) => Promise<string>;
|
|
514
|
+
/**
|
|
515
|
+
* Authenticate an inbound `CohortGossipV1` for a served `coord`: its `fromMember` peer-key signature
|
|
516
|
+
* must verify over the gossip image **and** `fromMember` must be a member of the cohort around `coord`.
|
|
517
|
+
* Live-signer mode only; absent → the bus skips the gate (key-less / unit composition).
|
|
518
|
+
*/
|
|
519
|
+
readonly verifyGossip?: (g: CohortGossipV1, coord: RingCoord) => boolean;
|
|
520
|
+
/**
|
|
521
|
+
* Broadcast a freshly threshold-signed promotion/demotion notice this engine produced over the
|
|
522
|
+
* `promote` protocol — to the cohort around `servedCoord`, plus the parent coord for a demotion.
|
|
523
|
+
* Wired by the host; absent in key-less / unit composition.
|
|
524
|
+
*/
|
|
525
|
+
readonly broadcastNotice?: (notice: PromotionNoticeV1 | DemotionNoticeV1, servedCoord: RingCoord) => void;
|
|
526
|
+
/**
|
|
527
|
+
* Hook a freshly published `MembershipCertV1` (from {@link CoordEngine.onStabilized} /
|
|
528
|
+
* {@link CoordEngine.pumpMembership}) into the node's verifier cache, so this node can verify inbound
|
|
529
|
+
* notices signed by its own cohort without a network refetch. Absent in unit composition.
|
|
530
|
+
*/
|
|
531
|
+
readonly onCertPublished?: (cert: MembershipCertV1) => void;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Build and start a {@link CohortTopicHost} on `node` over `fret`. Registers the four protocols and
|
|
536
|
+
* sets FRET's activity handler. Async because the node's ring coordinate is derived via FRET's
|
|
537
|
+
* `hashPeerId`.
|
|
538
|
+
*/
|
|
539
|
+
export async function createCohortTopicHost(node: Libp2p, fret: FretService, options: CohortTopicHostOptions = {}): Promise<CohortTopicHost> {
|
|
540
|
+
const profile = options.profile ?? coreProfile();
|
|
541
|
+
const protocols = options.protocols ?? DEFAULT_COHORT_TOPIC_PROTOCOLS;
|
|
542
|
+
const wantK = options.wantK ?? 16;
|
|
543
|
+
const minSigs = options.minSigs ?? DEFAULT_MIN_SIGS;
|
|
544
|
+
const fanout = options.fanout ?? 16;
|
|
545
|
+
const maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
546
|
+
const gossipIntervalMs = options.gossipIntervalMs ?? DEFAULT_GOSSIP_INTERVAL_MS;
|
|
547
|
+
|
|
548
|
+
const hash = new RingHash();
|
|
549
|
+
const selfPeerStr = node.peerId.toString();
|
|
550
|
+
const selfMemberBytes = peerIdToBytes(node.peerId); // dialable member id
|
|
551
|
+
const selfCoord = await hashPeerId(node.peerId); // ring position P (the participant gossip handle)
|
|
552
|
+
const addressing = createTierAddressing(hash, fanout);
|
|
553
|
+
|
|
554
|
+
// --- cohort resolver (FRET two-sided assembly around a coord) ---
|
|
555
|
+
const resolver: CohortPeerResolver = {
|
|
556
|
+
cohortPeers(coord: RingCoord, wants: number): string[] {
|
|
557
|
+
return fret.assembleCohort(coord, wants);
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// --- ports (node-wide singletons, injected into every coord engine) ---
|
|
562
|
+
const router = new FretTopicRouter(node, fret, { registerProtocol: protocols.register, maxBytes });
|
|
563
|
+
const sizeEstimator = new FretSizeEstimator(fret);
|
|
564
|
+
const gossipTransport = new FretCohortGossipTransport(node, resolver, { gossipProtocol: protocols.gossip, wants: wantK, selfPeerId: selfPeerStr });
|
|
565
|
+
const membershipSource = new FretMembershipSource(node, resolver, { membershipProtocol: protocols.membership, wants: wantK, maxBytes });
|
|
566
|
+
const publishSink = new FretMembershipPublishSink();
|
|
567
|
+
|
|
568
|
+
const slots = createSlotAssigner(hash);
|
|
569
|
+
const barometer = createLoadBarometer();
|
|
570
|
+
|
|
571
|
+
// Participant-side verifier signer: verify-only (it never assembles), so it needs no key / dial seam.
|
|
572
|
+
// The real k − x assembly lives in each CoordEngine's own threshold signer (constructed per coord).
|
|
573
|
+
const verifyingSigner = createCohortSigner(createVerifyOnlyThresholdCrypto(), minSigs);
|
|
574
|
+
|
|
575
|
+
// Collect one cohort member's `/sign` endorsement over the new fifth protocol.
|
|
576
|
+
const dialSign = async (peerIdStr: string, request: SignRequestV1): Promise<SignReplyV1> => {
|
|
577
|
+
const reply = await requestResponse(node, peerIdFromString(peerIdStr), protocols.sign, encodeCohortMessage(request, maxBytes), maxBytes);
|
|
578
|
+
return validateSignReplyV1(decodeCohortMessage(reply, maxBytes));
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
/** FRET assembly around `coord`: self prepended + deduped; epoch = H(sorted member join). */
|
|
582
|
+
const cohortAround = (coord: RingCoord): CohortSnapshotView => {
|
|
583
|
+
const peerStrs = fret.assembleCohort(coord, wantK);
|
|
584
|
+
const members = [selfMemberBytes, ...peerStrs.filter((p) => p !== selfPeerStr).map((p) => peerIdToBytes(p))];
|
|
585
|
+
// Deterministic epoch from the sorted member set so a membership change rotates the epoch.
|
|
586
|
+
const epochInput = members.map(bytesToB64url).sort().join("|");
|
|
587
|
+
const cohortEpoch = hash.H(new TextEncoder().encode(epochInput));
|
|
588
|
+
return { members, cohortEpoch };
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// --- participant peer-key signing seam (gap 2) ---
|
|
592
|
+
// The live signer needs the node's libp2p key (libp2p does not expose it off `node.peerId`, so it
|
|
593
|
+
// arrives via options). When absent we keep the interim empty-string signer AND skip inbound
|
|
594
|
+
// participant-signature verification, so key-less unit/mock flows still compose. The signer id is
|
|
595
|
+
// the participant's wire identity (`participantCoord` / `participantId`), which IS its dialable
|
|
596
|
+
// peer-id bytes (db-core threads `self = selfMemberBytes`), so it round-trips to a verifiable key.
|
|
597
|
+
const participantSigner = createParticipantSigner(options.privateKey, log);
|
|
598
|
+
const verifyRegisterSig = options.privateKey === undefined
|
|
599
|
+
? undefined
|
|
600
|
+
: (reg: RegisterV1): boolean =>
|
|
601
|
+
reg.signature.length > 0 &&
|
|
602
|
+
verifyPeerSig(b64urlToBytes(reg.participantCoord), registerSigningPayload(reg), b64urlToBytes(reg.signature));
|
|
603
|
+
const verifyParticipantSig = options.privateKey === undefined
|
|
604
|
+
? undefined
|
|
605
|
+
: (renew: RenewV1): boolean =>
|
|
606
|
+
renew.signature.length > 0 &&
|
|
607
|
+
verifyPeerSig(b64urlToBytes(renew.participantId), renewSigningPayload(renew), b64urlToBytes(renew.signature));
|
|
608
|
+
|
|
609
|
+
// --- intra-cohort gossip authenticity seam (gap 5) ---
|
|
610
|
+
// Same peer-key signing pattern as register/renew, applied to the gossip envelope: the originator
|
|
611
|
+
// signs its canonical image, and a receiver drops a frame whose `fromMember` signature does not
|
|
612
|
+
// verify or that comes from a non-cohort member (so willingness/load can't be spoofed and forged
|
|
613
|
+
// records can't replicate). Key-less mode ships/accepts unsigned gossip (documented interim), exactly
|
|
614
|
+
// like the participant signer.
|
|
615
|
+
const nodeKey = options.privateKey;
|
|
616
|
+
const signGossip = nodeKey === undefined
|
|
617
|
+
? undefined
|
|
618
|
+
: async (g: CohortGossipSignable): Promise<string> => bytesToB64url(await signPeer(nodeKey, cohortGossipSigningPayload(g)));
|
|
619
|
+
const verifyGossip = nodeKey === undefined
|
|
620
|
+
? undefined
|
|
621
|
+
: (g: CohortGossipV1, coord: RingCoord): boolean => {
|
|
622
|
+
if (g.signature.length === 0) {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
const fromBytes = b64urlToBytes(g.fromMember);
|
|
626
|
+
if (!verifyPeerSig(fromBytes, cohortGossipSigningPayload(g), b64urlToBytes(g.signature))) {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
const members = cohortAround(coord).members.map(bytesToPeerIdString);
|
|
630
|
+
return members.includes(bytesToPeerIdString(fromBytes));
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// --- outbound notice broadcast (gap 4) ---
|
|
634
|
+
// A coord engine that threshold-signs a promotion/demotion notice hands it here; we fan it over the
|
|
635
|
+
// `promote` protocol to the cohort around the served coord (siblings adopt the state) and, for a
|
|
636
|
+
// demotion, additionally to the parent coord (childCohortCount bookkeeping). Reuses the gossip
|
|
637
|
+
// transport's cohort peer resolution.
|
|
638
|
+
const broadcastNotice = (notice: PromotionNoticeV1 | DemotionNoticeV1, servedCoord: RingCoord): void => {
|
|
639
|
+
const frame = encodeCohortMessage(notice, maxBytes);
|
|
640
|
+
for (const coord of noticeBroadcastCoords(notice, servedCoord)) {
|
|
641
|
+
gossipTransport.broadcastOver(protocols.promote, coord, frame);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
// --- anti-DoS (gap 6) ---
|
|
646
|
+
// The bootstrap-evidence policy is node-level (a tier→verifier policy with no per-coord state), so it
|
|
647
|
+
// is built once and shared by every coord engine. The per-coord guards (rate limiter, replay guard,
|
|
648
|
+
// topic budget) are built per CoordEngine from `antiDos` below — they key on `(peer, topic)` /
|
|
649
|
+
// per-cohort topic state, which is coord-scoped, and must not share state across coords.
|
|
650
|
+
// The signed-parent-reference existence view (cohort-topic-bootstrap-parent-reference). A test override
|
|
651
|
+
// (`antiDos.parentTopicView`) wins; otherwise the host default, tier-routed over the FRET membership
|
|
652
|
+
// cache (T2/T3) and the optional committed reader (T0/T1 — fail-closed without one, committed-tier
|
|
653
|
+
// integrity). Synchronous + local: it reads the in-memory caches the node already holds, never a dial.
|
|
654
|
+
const parentTopicView = options.antiDos?.parentTopicView ?? createDefaultParentTopicView({
|
|
655
|
+
membershipSource,
|
|
656
|
+
addressing,
|
|
657
|
+
committedReader: options.committedParentTopicReader,
|
|
658
|
+
});
|
|
659
|
+
// Does this host actually have a committed-existence backing to gate T0/T1 parent-refs against? Only an
|
|
660
|
+
// explicit `parentTopicView` override (the test seam / future production index) or a wired
|
|
661
|
+
// `committedParentTopicReader` provides one. Absent both (today's production node), a T0/T1 root cannot
|
|
662
|
+
// mint any acceptable parent-ref — a root has no parent and the default view fails T0/T1 closed — so the
|
|
663
|
+
// policy keeps T0/T1 permissive-but-logged rather than regressing cold-start origination. See
|
|
664
|
+
// {@link createBootstrapEvidencePolicy}.
|
|
665
|
+
const hasCommittedParentBacking =
|
|
666
|
+
options.antiDos?.parentTopicView !== undefined || options.committedParentTopicReader !== undefined;
|
|
667
|
+
const bootstrapEvidence = createBootstrapEvidencePolicy(options.antiDos, hash, log, parentTopicView, hasCommittedParentBacking);
|
|
668
|
+
|
|
669
|
+
// Node-level `promote`-handler anti-abuse gate (`cohort-topic-promote-handler-verify-amplification`):
|
|
670
|
+
// a per-(peer, topic) rate limiter (own instance — the register-path limiter is per-coord inside each
|
|
671
|
+
// engine; this handler is node-level) plus the per-(topic, tier) effectiveAt high-water. Defaults to
|
|
672
|
+
// `register_rate_per_peer` (4 / min / peer / topic) with exponential back-off.
|
|
673
|
+
const promoteGate = createPromoteGate(options.antiDos?.rateLimiter);
|
|
674
|
+
|
|
675
|
+
const ctx: CoordEngineContext = {
|
|
676
|
+
hash,
|
|
677
|
+
addressing,
|
|
678
|
+
slots,
|
|
679
|
+
barometer,
|
|
680
|
+
transport: gossipTransport,
|
|
681
|
+
profile,
|
|
682
|
+
selfMemberBytes,
|
|
683
|
+
wantK,
|
|
684
|
+
minSigs,
|
|
685
|
+
maxBytes,
|
|
686
|
+
publishSink,
|
|
687
|
+
privateKey: options.privateKey,
|
|
688
|
+
router,
|
|
689
|
+
antiDos: {
|
|
690
|
+
rateLimiter: options.antiDos?.rateLimiter,
|
|
691
|
+
replayGuard: options.antiDos?.replayGuard,
|
|
692
|
+
topicBudget: options.antiDos?.topicBudget,
|
|
693
|
+
},
|
|
694
|
+
bootstrapEvidence,
|
|
695
|
+
promotionConfig: options.promotion,
|
|
696
|
+
dialSign,
|
|
697
|
+
cohortAround,
|
|
698
|
+
verifyRegisterSig,
|
|
699
|
+
verifyParticipantSig,
|
|
700
|
+
signGossip,
|
|
701
|
+
verifyGossip,
|
|
702
|
+
broadcastNotice,
|
|
703
|
+
// Cache this node's own freshly-published cohort cert into the verifier, so an inbound notice
|
|
704
|
+
// signed by this node's cohort verifies locally without a network refetch. `verifier` is declared
|
|
705
|
+
// just below; the closure only runs on a (later) publish, after it is initialized.
|
|
706
|
+
onCertPublished: (cert: MembershipCertV1): void => verifier.cache(cert),
|
|
707
|
+
};
|
|
708
|
+
const registry = createCoordRegistry(ctx);
|
|
709
|
+
|
|
710
|
+
// --- intra-cohort sign endorsement (the `/sign` handler body) ---
|
|
711
|
+
// A member dials us to endorse a threshold-signed artifact; we sign the exact request payload iff we
|
|
712
|
+
// and the requester share the cohort+epoch around `coord`. Exported `handleSignRequest` is the testable
|
|
713
|
+
// core; here we bind it to this node's key + the FRET assembly around the requested coord.
|
|
714
|
+
const signEndorse = (request: SignRequestV1, fromPeerStr: string): Promise<SignReplyV1> =>
|
|
715
|
+
handleSignRequest(request, fromPeerStr, {
|
|
716
|
+
privateKey: options.privateKey,
|
|
717
|
+
selfMember: selfMemberBytes,
|
|
718
|
+
cohortMembersAround: (coord: RingCoord): string[] => cohortAround(coord).members.map(bytesToPeerIdString),
|
|
719
|
+
currentEpoch: (coord: RingCoord): Uint8Array => cohortAround(coord).cohortEpoch,
|
|
720
|
+
// Rotation endorsement consults the served coord engine's prior-epoch membership history; a coord
|
|
721
|
+
// this node does not serve has no engine, so the hand-off is refused (no spurious instantiation).
|
|
722
|
+
priorCohortMembersAt: (coord: RingCoord, epoch: Uint8Array): readonly string[] | undefined =>
|
|
723
|
+
registry.findByCoord(coord)?.cohortIdentityAt(epoch),
|
|
724
|
+
// Membership binding: re-derive our own canonical cert fields from the SAME `cohortAround` snapshot
|
|
725
|
+
// the per-coord publisher signs over, so a falsified `members` / `cohortCoord` / internal-epoch
|
|
726
|
+
// payload is refused. `stabilizedAt: 0` — the endorser ignores it (it only bounds the value
|
|
727
|
+
// far-future via `now`), matching the dep contract (cohort-topic-sign-endorsement-payload-binding).
|
|
728
|
+
expectedMembershipFields: (coord: RingCoord): { cohortCoord: string; cohortEpoch: string; members: string[] } => {
|
|
729
|
+
const snap = cohortAround(coord);
|
|
730
|
+
const { cohortCoord, cohortEpoch, members } = membershipCertSignable({
|
|
731
|
+
coord,
|
|
732
|
+
cohortEpoch: snap.cohortEpoch,
|
|
733
|
+
members: snap.members,
|
|
734
|
+
stabilizedAt: 0,
|
|
735
|
+
});
|
|
736
|
+
return { cohortCoord, cohortEpoch, members };
|
|
737
|
+
},
|
|
738
|
+
// Wall clock for the `stabilizedAt` far-future bound. Production: Date.now. A virtual-time harness
|
|
739
|
+
// injects a non-tripping clock (its publish `stabilizedAt` is synthetic, not wall-clock).
|
|
740
|
+
now: options.now ?? ((): number => Date.now()),
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// --- participant-side composition (node scope) ---
|
|
744
|
+
// The participant service exposes a node-level gossip handle (around the node's own ring position)
|
|
745
|
+
// and the membership verifier; its register/renew walk drives the FRET router, not a coord engine.
|
|
746
|
+
const participantStore = createRegistrationStore();
|
|
747
|
+
const participantGossipBus = createCohortGossipBus({
|
|
748
|
+
transport: gossipTransport,
|
|
749
|
+
store: participantStore,
|
|
750
|
+
coord: selfCoord,
|
|
751
|
+
localEpoch: (): Uint8Array => cohortAround(selfCoord).cohortEpoch,
|
|
752
|
+
// Same per-coord auth gate as the coord engines: only a signed gossip from a member of the cohort
|
|
753
|
+
// around the node's own ring position merges here (live-signer mode); absent in key-less composition.
|
|
754
|
+
verifyInbound: verifyGossip === undefined ? undefined : (g): boolean => verifyGossip(g, selfCoord),
|
|
755
|
+
});
|
|
756
|
+
const certSource: IMembershipSource = membershipSource;
|
|
757
|
+
const membershipRouter = createMembershipSourceRouter({ committed: certSource, fret: certSource });
|
|
758
|
+
// --- direct trust anchor (cohort-topic-trust-anchor-fret-binding) ---
|
|
759
|
+
// Bind the db-core trust gate's direct anchor to the FRET ring: for a coord this node serves (a covered,
|
|
760
|
+
// non-partitioned T2/T3 coord whose cohort includes self), the cert's signing quorum is checked against
|
|
761
|
+
// the ring's two-sided assembly — a forged unrelated keyset is `"rejected"`, a legit (slack-tolerant)
|
|
762
|
+
// cohort `"anchored"`, everything else `"unknown"` (→ chain / TOFU). Committed tiers (T0/T1) stay
|
|
763
|
+
// `"unknown"` here so this composes with the future tx-log anchor rather than fighting it. `wantK` matches
|
|
764
|
+
// the cohort size `cohortAround` publishes certs over; `selfPeerStr` is the coverage handle.
|
|
765
|
+
const trustAnchor = new FretTrustAnchor(fret, { k: wantK, selfPeerId: selfPeerStr });
|
|
766
|
+
const verifier = createMembershipVerifier({
|
|
767
|
+
signer: verifyingSigner,
|
|
768
|
+
router: membershipRouter,
|
|
769
|
+
minSigs,
|
|
770
|
+
anchor: trustAnchor,
|
|
771
|
+
// Genesis trust roots (network-config; empty by default → no roots, identical to pre-seam behavior).
|
|
772
|
+
trustRoots: options.genesisTrustRoots ?? [],
|
|
773
|
+
});
|
|
774
|
+
// --- participant-side cold-start evidence builder (gap 6) ---
|
|
775
|
+
// Mints the evidence the participant attaches on a cold-root `bootstrap: true` re-issue. PoW (T2/T3) is
|
|
776
|
+
// keyless, so even a key-less host can bootstrap those tiers; the proof is bound to the register's own
|
|
777
|
+
// (topicId, tier, participantCoord, timestamp) tuple so a verifier reconstructs the same image. T0/T1
|
|
778
|
+
// carries no evidence here (the builder supports an `endorse` self-vouch seam — see
|
|
779
|
+
// `bootstrap-evidence-builder.ts` — but origination at those tiers is the committed-parent-reference
|
|
780
|
+
// follow-on `cohort-topic-bootstrap-parent-reference`, so it is intentionally left unwired for now).
|
|
781
|
+
const buildBootstrapEvidence = createBootstrapEvidenceBuilder({
|
|
782
|
+
hash,
|
|
783
|
+
bits: options.antiDos?.powDifficultyBits,
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
const service = createCohortTopicService({
|
|
787
|
+
// `self` is the dialable peer-id bytes (not the ring coord): db-core carries it as the
|
|
788
|
+
// participant's wire identity, so the cohort can verify its peer-key signature (see signing seam).
|
|
789
|
+
self: selfMemberBytes,
|
|
790
|
+
hash,
|
|
791
|
+
router,
|
|
792
|
+
sizeEstimator,
|
|
793
|
+
signer: participantSigner,
|
|
794
|
+
gossipBus: participantGossipBus,
|
|
795
|
+
verifier,
|
|
796
|
+
buildBootstrapEvidence,
|
|
797
|
+
config: { fanout, wantK, minSigs, maxMessageBytes: maxBytes },
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// --- register dispatch: recompute the served coord and run the cohort decision on its engine ---
|
|
801
|
+
const dispatchRegister = async (reg: RegisterV1, fretCohort: readonly string[] | undefined, now: number): Promise<RegisterReplyV1> => {
|
|
802
|
+
const topicId = b64urlToBytes(reg.topicId);
|
|
803
|
+
const participantCoord = b64urlToBytes(reg.participantCoord);
|
|
804
|
+
// FRET's ActivityHandler does not carry the routed key, so recompute it from the frame. For tier
|
|
805
|
+
// `d` this equals the participant's `coord_d(self, topicId)` routing key by construction, i.e. the
|
|
806
|
+
// coordinate FRET routed to (§Tier addressing).
|
|
807
|
+
const servedCoord = addressing.coord(reg.treeTier, participantCoord, topicId);
|
|
808
|
+
const coordEngine = registry.forCoord(servedCoord, reg.treeTier, participantCoord);
|
|
809
|
+
if (fretCohort !== undefined) {
|
|
810
|
+
crossCheckCohort(fret, wantK, servedCoord, fretCohort);
|
|
811
|
+
}
|
|
812
|
+
// `parentCoord` for a cold-start forwarder's parent registration; undefined at the root.
|
|
813
|
+
const parentCoord = reg.treeTier > 0 ? addressing.coord(reg.treeTier - 1, participantCoord, topicId) : undefined;
|
|
814
|
+
return coordEngine.engine.handleRegister(reg, { followOn: false, treeTier: reg.treeTier, parentCoord }, now);
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
// --- protocol handlers + activity callback ---
|
|
818
|
+
// Await registration so the host is not returned (and dialed) before the five handlers are live —
|
|
819
|
+
// and, crucially, before the gossip driver below starts ticking (no tick may run on a half-wired node).
|
|
820
|
+
await registerProtocolHandlers(node, protocols, registry, dispatchRegister, signEndorse, verifier, promoteGate, gossipTransport, publishSink, membershipSource, selfCoord, maxBytes);
|
|
821
|
+
fret.setActivityHandler(async (activity: string, cohort: string[]): Promise<{ commitCertificate: string }> => {
|
|
822
|
+
const reg = validateRegisterV1(decodeCohortMessage(b64urlToBytes(activity), maxBytes));
|
|
823
|
+
const reply = await dispatchRegister(reg, cohort, Date.now());
|
|
824
|
+
return { commitCertificate: bytesToB64url(encodeCohortMessage(reply, maxBytes)) };
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// --- periodic gossip-cadence driver (gap 5) ---
|
|
828
|
+
// db-core has no timer port (confirmed), so the host owns a single raw `setInterval`. Each tick drives
|
|
829
|
+
// every live coord engine's gossip round + membership refresh + demotion check; the membership-refresh
|
|
830
|
+
// (5 min) and demotion (5 min) hysteresis self-gate on elapsed time, so a fast tick is safe and cheap
|
|
831
|
+
// (idle empty engines build no frame). A re-entrancy guard skips a tick that overlaps a slow prior one;
|
|
832
|
+
// `stopped` short-circuits any tick that fires after `stop()`.
|
|
833
|
+
let stopped = false;
|
|
834
|
+
let ticking = false;
|
|
835
|
+
const driveTick = async (): Promise<void> => {
|
|
836
|
+
if (stopped || ticking) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
ticking = true;
|
|
840
|
+
try {
|
|
841
|
+
const now = Date.now();
|
|
842
|
+
for (const engine of registry.all()) {
|
|
843
|
+
if (stopped) {
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
await engine.gossipRound(now);
|
|
848
|
+
await engine.pumpMembership(now);
|
|
849
|
+
await engine.demotionTick(now);
|
|
850
|
+
} catch (err) {
|
|
851
|
+
log("cohort-topic: gossip tick failed for a coord engine: %o", err);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// Proactive idle reclaim of the node-level promote-gate limiter on the same cadence the per-coord
|
|
855
|
+
// limiters sweep on. The limiter's inline `maxKeys` cap is the hard worst-case bound; this sweep is
|
|
856
|
+
// the steady-state reclaim of idle `(peer, topic)` keys. Runs after the per-engine loop, inside the
|
|
857
|
+
// `ticking` guard and `stopped` short-circuit, so it is single-threaded with no race on `stop()`.
|
|
858
|
+
promoteGate.rateLimiter.sweep(now);
|
|
859
|
+
} finally {
|
|
860
|
+
ticking = false;
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
const timer = setInterval((): void => {
|
|
864
|
+
void driveTick();
|
|
865
|
+
}, gossipIntervalMs);
|
|
866
|
+
// Node timers keep the event loop alive; cohort gossip should not pin a process that is otherwise idle.
|
|
867
|
+
(timer as { unref?: () => void }).unref?.();
|
|
868
|
+
|
|
869
|
+
return {
|
|
870
|
+
service,
|
|
871
|
+
registry,
|
|
872
|
+
protocols,
|
|
873
|
+
profile,
|
|
874
|
+
gossipTransport,
|
|
875
|
+
promoteGate,
|
|
876
|
+
stop: async (): Promise<void> => {
|
|
877
|
+
stopped = true;
|
|
878
|
+
clearInterval(timer);
|
|
879
|
+
registry.close();
|
|
880
|
+
participantGossipBus.close();
|
|
881
|
+
await node.unhandle(cohortTopicProtocolList(protocols));
|
|
882
|
+
},
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// --- participant signing seam ---
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* The participant body signer. With a node key it peer-key-signs the canonical register/renew byte
|
|
890
|
+
* image (base64url). Without one it is the interim empty-string signer with a one-time warn that the
|
|
891
|
+
* bodies are unsigned — keeping key-less mock/unit flows composable (e.g. the four-protocol handshake
|
|
892
|
+
* test). The `signRegister`/`signRenew` bodies are the wire-payload-helper inputs verbatim.
|
|
893
|
+
*/
|
|
894
|
+
function createParticipantSigner(
|
|
895
|
+
privateKey: PrivateKey | undefined,
|
|
896
|
+
log: (formatter: string, ...args: unknown[]) => void,
|
|
897
|
+
): ParticipantSigner {
|
|
898
|
+
if (privateKey === undefined) {
|
|
899
|
+
let warned = false;
|
|
900
|
+
const warnOnce = (): void => {
|
|
901
|
+
if (warned) {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
warned = true;
|
|
905
|
+
log("no privateKey supplied to createCohortTopicHost; participant RegisterV1/RenewV1 bodies are UNSIGNED (interim — supply options.privateKey for peer-key signing)");
|
|
906
|
+
};
|
|
907
|
+
return {
|
|
908
|
+
signRegister: (): Promise<string> => {
|
|
909
|
+
warnOnce();
|
|
910
|
+
return Promise.resolve("");
|
|
911
|
+
},
|
|
912
|
+
signRenew: (): Promise<string> => {
|
|
913
|
+
warnOnce();
|
|
914
|
+
return Promise.resolve("");
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
signRegister: async (body): Promise<string> => bytesToB64url(await signPeer(privateKey, registerSigningPayload(body))),
|
|
920
|
+
signRenew: async (body): Promise<string> => bytesToB64url(await signPeer(privateKey, renewSigningPayload(body))),
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// --- anti-DoS: node-level bootstrap-evidence policy (gap 6) ---
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Build the node-level {@link BootstrapEvidence} policy a cold root demands of a `bootstrap: true`
|
|
928
|
+
* registration (`docs/cohort-topic.md` §Anti-DoS bullet 4). The policy itself (tier-dependent: T0/T1
|
|
929
|
+
* need a signed parent reference; T2/T3 accept PoW OR reputation OR a parent reference) is real db-core
|
|
930
|
+
* logic — this only supplies the **verifiers**, which db-core deliberately does not embed (no specific
|
|
931
|
+
* PoW / reputation / committed-work scheme).
|
|
932
|
+
*
|
|
933
|
+
* Resolution per verifier (an injected `antiDos.bootstrapEvidence` override always wins — the test seam):
|
|
934
|
+
*
|
|
935
|
+
* - `verifyPoW` — when **configured**, the real {@link createPoWVerifier} (self-contained: one hash over
|
|
936
|
+
* the bound preimage). This is a working PoW path, not deny.
|
|
937
|
+
* - `verifyReputation` — when a reputation **view** is supplied, the real {@link createReputationVerifier}
|
|
938
|
+
* (a referee peer-key-signs the bound image; the cohort checks the signature + the referee's local
|
|
939
|
+
* reputation). Else (configured, no view) fail closed.
|
|
940
|
+
* - `verifyParentReference` — the real {@link createParentReferenceVerifier} over `parentTopicView`: a
|
|
941
|
+
* participant-signed reference to a parent topic that the node locally knows exists (committed/membership
|
|
942
|
+
* state). It is the *only* accepted evidence for T0/T1 and the third T2/T3 option. The view is always
|
|
943
|
+
* available (the host builds a default from the membership cache + addressing), so this is the real
|
|
944
|
+
* verifier at T2/T3 and at T0/T1 **once a committed backing is wired**.
|
|
945
|
+
*
|
|
946
|
+
* **T0/T1 without a committed backing (the cold-start-origination interim posture).** A real production
|
|
947
|
+
* node wires a reputation view (so it is *configured*) but, today, no committed-existence backing
|
|
948
|
+
* (`hasCommittedParentBacking === false`): there is no coord-keyed committed-membership index yet, so the
|
|
949
|
+
* default `parentTopicView` fails T0/T1 closed, and the participant-side builder mints no parentRef for a
|
|
950
|
+
* brand-new root (a root has no parent to reference). At those tiers the policy consults *only*
|
|
951
|
+
* `verifyParentReference`, so a real, unfilled T0/T1 parent-ref gate would make cold-start origination
|
|
952
|
+
* impossible. While that backing does not exist we therefore keep T0/T1 **permissive-but-logged** when
|
|
953
|
+
* `hasCommittedParentBacking` is false, and run the real verifier otherwise — i.e. at T2/T3 always, and at
|
|
954
|
+
* T0/T1 once a committed backing IS supplied (the test seam `antiDos.parentTopicView`, or a future
|
|
955
|
+
* production `committedParentTopicReader`). This is the documented refinement of the parent-reference work,
|
|
956
|
+
* narrowed so it does not regress the just-landed real T0/T1 gating exercised with an explicit view.
|
|
957
|
+
*
|
|
958
|
+
* "Configured" = any reputation view or explicit `bootstrapEvidence` override is set; once configured, an
|
|
959
|
+
* unfilled verifier fails **closed** so a banned/low-rep referee cannot slip the T2/T3
|
|
960
|
+
* `verifyPoW || verifyReputation || verifyParentReference` disjunction. The permissive-but-logged
|
|
961
|
+
* fallback is reserved for the *entirely unconfigured* interim node (and the configured-but-no-committed-
|
|
962
|
+
* backing T0/T1 origination path above) — a one-time warning, never an undefined gate — so the
|
|
963
|
+
* db-core/mock-tier flows that bootstrap tier-0 without evidence still pass.
|
|
964
|
+
*
|
|
965
|
+
* @param hasCommittedParentBacking Whether this host has a committed-existence backing to gate T0/T1
|
|
966
|
+
* parent-refs against (an explicit `antiDos.parentTopicView` override or a wired
|
|
967
|
+
* `committedParentTopicReader`). False on today's production node, where T0/T1 stays permissive.
|
|
968
|
+
*/
|
|
969
|
+
function createBootstrapEvidencePolicy(
|
|
970
|
+
antiDos: CohortTopicAntiDosOptions | undefined,
|
|
971
|
+
hash: RingHash,
|
|
972
|
+
log: (formatter: string, ...args: unknown[]) => void,
|
|
973
|
+
parentTopicView: BootstrapParentTopicView,
|
|
974
|
+
hasCommittedParentBacking: boolean,
|
|
975
|
+
): BootstrapEvidence {
|
|
976
|
+
const overrides = antiDos?.bootstrapEvidence;
|
|
977
|
+
const reputation = antiDos?.reputation;
|
|
978
|
+
|
|
979
|
+
// Once ANY real gating is configured (a reputation view or explicit verifiers), an unfilled verifier
|
|
980
|
+
// must fail **closed** (deny), not open — otherwise a permissive verifier short-circuits the T2/T3
|
|
981
|
+
// `||` disjunction and admits even a banned peer. The permissive-but-logged fallback below is
|
|
982
|
+
// therefore reserved for the *entirely unconfigured* interim node.
|
|
983
|
+
const configured = overrides !== undefined || reputation !== undefined;
|
|
984
|
+
// Mirror the db-core policy's tier split (an override config wins, else the default) so the T0/T1 branch
|
|
985
|
+
// of the parent-reference gate below matches the tiers at which the policy consults it exclusively.
|
|
986
|
+
const maxNoPowTier = overrides?.config?.maxNoPowTier ?? DEFAULT_MAX_NO_POW_TIER;
|
|
987
|
+
|
|
988
|
+
// The real, self-contained PoW verifier — available whenever configured (no view / subsystem needed).
|
|
989
|
+
const realPoW = createPoWVerifier({ hash, bits: antiDos?.powDifficultyBits });
|
|
990
|
+
// The real referee verifier — only when a reputation view is supplied (the T2/T3 reputation path).
|
|
991
|
+
const realReputation = reputation === undefined
|
|
992
|
+
? undefined
|
|
993
|
+
: createReputationVerifier({ reputation, deprioritizeThreshold: antiDos?.deprioritizeThreshold });
|
|
994
|
+
// The real signed-parent-reference verifier over the existence view (T2/T3 always; T0/T1 once a committed
|
|
995
|
+
// backing is wired). Replaces the interim reputation stand-in: it demands a real, existing parent.
|
|
996
|
+
const realParentReference = createParentReferenceVerifier({ parentTopicView });
|
|
997
|
+
|
|
998
|
+
const deny = (): boolean => false;
|
|
999
|
+
|
|
1000
|
+
// Permissive-but-logged fallback (one warning total): keeps the gate defined while the production
|
|
1001
|
+
// evidence schemes are unwired, instead of silently leaving cold-root bootstrap unauthenticated.
|
|
1002
|
+
let warned = false;
|
|
1003
|
+
const permissive = (kind: string): ((reg: RegisterV1) => boolean) => (reg: RegisterV1): boolean => {
|
|
1004
|
+
void reg;
|
|
1005
|
+
if (!warned) {
|
|
1006
|
+
warned = true;
|
|
1007
|
+
log("cohort-topic anti-DoS: bootstrap-evidence %s verifier is PERMISSIVE — no PoW/reputation view wired, so cold-root bootstrap is NOT cryptographically gated (interim; inject antiDos.bootstrapEvidence/reputation, see cohort-topic-bootstrap-evidence-scheme)", kind);
|
|
1008
|
+
}
|
|
1009
|
+
return true;
|
|
1010
|
+
};
|
|
1011
|
+
// An unconfigured verifier: deny when the policy is otherwise configured (fail closed), else permissive.
|
|
1012
|
+
const fallback = (kind: string): ((reg: RegisterV1) => boolean) => configured ? deny : permissive(kind);
|
|
1013
|
+
|
|
1014
|
+
// The configured parent-reference gate. At T0/T1 (`reg.tier <= maxNoPowTier`) with NO committed backing,
|
|
1015
|
+
// a brand-new root cannot mint any acceptable parent-ref (it has no parent; the default view fails closed)
|
|
1016
|
+
// and the policy consults only this verifier there — so admit permissively-but-logged rather than regress
|
|
1017
|
+
// cold-start origination. At T2/T3, and at T0/T1 once a committed backing IS wired (the test seam / future
|
|
1018
|
+
// production index), run the real verifier.
|
|
1019
|
+
const permissiveT0T1 = permissive("parent-reference (T0/T1, no committed backing)");
|
|
1020
|
+
const parentReferenceGate = (reg: RegisterV1): boolean => {
|
|
1021
|
+
if (reg.tier <= maxNoPowTier && !hasCommittedParentBacking) {
|
|
1022
|
+
return permissiveT0T1(reg);
|
|
1023
|
+
}
|
|
1024
|
+
return realParentReference(reg);
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
return createBootstrapEvidence({
|
|
1028
|
+
// Configured ⇒ the real PoW path; unconfigured ⇒ permissive (preserves the bare-host tier-0 flows).
|
|
1029
|
+
verifyPoW: overrides?.verifyPoW ?? (configured ? realPoW : fallback("proof-of-work")),
|
|
1030
|
+
verifyReputation: overrides?.verifyReputation ?? realReputation ?? fallback("reputation"),
|
|
1031
|
+
// The real signed-parent-reference verifier when configured (an explicit override still wins), gated so
|
|
1032
|
+
// T0/T1-without-committed-backing stays permissive; unconfigured ⇒ permissive (preserves the bare-host
|
|
1033
|
+
// tier-0 flows). No longer the reputation stand-in.
|
|
1034
|
+
verifyParentReference: overrides?.verifyParentReference ?? (configured ? parentReferenceGate : fallback("parent-reference")),
|
|
1035
|
+
config: overrides?.config,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// --- cold-start: forwarder → parent registration transport (gap 7) ---
|
|
1040
|
+
|
|
1041
|
+
/** Inputs to {@link registerForwarderWithParent} (captured per coord engine + per instantiating register). */
|
|
1042
|
+
interface ForwarderLink {
|
|
1043
|
+
readonly topicId: Uint8Array;
|
|
1044
|
+
/** The tier-`(d − 1)` parent cohort coord this link routes to. */
|
|
1045
|
+
readonly parentCoord: Uint8Array;
|
|
1046
|
+
/** The forwarder's tree tier `d` (`> 0` — the root never links to a parent). */
|
|
1047
|
+
readonly treeTier: number;
|
|
1048
|
+
/** The topic's capacity tier (T0–T3); stamps the link frame's `tier`. Defaults to 0 if absent. */
|
|
1049
|
+
readonly opTier?: number;
|
|
1050
|
+
/** The participant coord that seeded this engine — keeps the link frame's recompute consistent. */
|
|
1051
|
+
readonly participantCoord: Uint8Array;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Route a forwarder→parent link to `parentCoord` and resolve on the round-trip (the parent ack).
|
|
1056
|
+
*
|
|
1057
|
+
* The link is a `RegisterV1`-style frame routed over {@link ITopicRouter.routeAndAct} keyed at the
|
|
1058
|
+
* parent coord: it rides the parent's serving tier (`treeTier − 1`) with this engine's seed
|
|
1059
|
+
* `participantCoord`, so the parent recomputes `servedCoord = coord_{d−1}(participantCoord, topicId) =
|
|
1060
|
+
* parentCoord`. A fresh CSPRNG `correlationId` keeps it clear of the parent's replay guard on retry.
|
|
1061
|
+
* Resolution of the route is treated as the ack (richer child-link confirmation — the parent recording
|
|
1062
|
+
* `childCohortCount` over a dedicated child-link frame — is the follow-on
|
|
1063
|
+
* `cohort-topic-parent-child-link`); a rejection propagates so the cold-start manager keeps the
|
|
1064
|
+
* forwarder `awaiting_parent`.
|
|
1065
|
+
*/
|
|
1066
|
+
async function registerForwarderWithParent(ctx: CoordEngineContext, link: ForwarderLink): Promise<void> {
|
|
1067
|
+
const frame: RegisterV1 = {
|
|
1068
|
+
v: 1,
|
|
1069
|
+
topicId: bytesToB64url(link.topicId),
|
|
1070
|
+
tier: clampTier(link.opTier ?? 0),
|
|
1071
|
+
treeTier: Math.max(0, link.treeTier - 1),
|
|
1072
|
+
participantCoord: bytesToB64url(link.participantCoord),
|
|
1073
|
+
ttl: DEFAULT_TTL_MS,
|
|
1074
|
+
// Not a root cold-start: a follow-on link to an already-promoted parent, so no bootstrap evidence.
|
|
1075
|
+
bootstrap: false,
|
|
1076
|
+
timestamp: Date.now(),
|
|
1077
|
+
correlationId: bytesToB64url(randomBytes(16)),
|
|
1078
|
+
// Interim: the forwarder cohort cannot sign as the participant; the dedicated child-link frame
|
|
1079
|
+
// (follow-on) carries the cohort threshold signature instead.
|
|
1080
|
+
signature: "",
|
|
1081
|
+
};
|
|
1082
|
+
await ctx.router.routeAndAct(link.parentCoord, encodeCohortMessage(frame, ctx.maxBytes), { wantK: ctx.wantK, minSigs: ctx.minSigs });
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Clamp an op tier to the valid T0–T3 range so the link frame validates at a (future) real parent. */
|
|
1086
|
+
function clampTier(tier: number): number {
|
|
1087
|
+
if (!Number.isInteger(tier) || tier < 0) {
|
|
1088
|
+
return 0;
|
|
1089
|
+
}
|
|
1090
|
+
return tier > 3 ? 3 : tier;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// --- registry + coord engine ---
|
|
1094
|
+
|
|
1095
|
+
/** Build the lazy `servedCoord → CoordEngine` registry over the shared collaborators. */
|
|
1096
|
+
function createCoordRegistry(ctx: CoordEngineContext): CoordRegistry {
|
|
1097
|
+
const engines = new Map<string, CoordEngine>();
|
|
1098
|
+
return {
|
|
1099
|
+
forCoord(coord: RingCoord, treeTier: number, participantCoord: Uint8Array): CoordEngine {
|
|
1100
|
+
const key = bytesToB64url(coord);
|
|
1101
|
+
// Compute-if-absent, synchronously — no async gap, so two concurrent callers for the same coord
|
|
1102
|
+
// share one engine rather than racing to construct a second.
|
|
1103
|
+
let engine = engines.get(key);
|
|
1104
|
+
if (engine === undefined) {
|
|
1105
|
+
engine = createCoordEngine(ctx, coord, treeTier, participantCoord);
|
|
1106
|
+
engines.set(key, engine);
|
|
1107
|
+
}
|
|
1108
|
+
return engine;
|
|
1109
|
+
},
|
|
1110
|
+
findByCoord(coord: RingCoord): CoordEngine | undefined {
|
|
1111
|
+
return engines.get(bytesToB64url(coord));
|
|
1112
|
+
},
|
|
1113
|
+
findHolder(topicId: Uint8Array, participantId: Uint8Array): CoordEngine | undefined {
|
|
1114
|
+
for (const engine of engines.values()) {
|
|
1115
|
+
if (engine.holds(topicId, participantId)) {
|
|
1116
|
+
return engine;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return undefined;
|
|
1120
|
+
},
|
|
1121
|
+
findServing(topicId: Uint8Array, treeTier: number): CoordEngine | undefined {
|
|
1122
|
+
for (const engine of engines.values()) {
|
|
1123
|
+
if (engine.treeTier === treeTier && engine.servesTopic(topicId)) {
|
|
1124
|
+
return engine;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return undefined;
|
|
1128
|
+
},
|
|
1129
|
+
all(): readonly CoordEngine[] {
|
|
1130
|
+
return [...engines.values()];
|
|
1131
|
+
},
|
|
1132
|
+
close(): void {
|
|
1133
|
+
for (const engine of engines.values()) {
|
|
1134
|
+
engine.close();
|
|
1135
|
+
}
|
|
1136
|
+
engines.clear();
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* One observed cohort identity for a served coord: the deterministic epoch (`H(sorted member set)`),
|
|
1143
|
+
* its base64url key, and the member set in both byte and peer-id-string form. The epoch is a pure
|
|
1144
|
+
* function of the member set, so an epoch match implies a member-set match.
|
|
1145
|
+
*/
|
|
1146
|
+
interface CohortIdentity {
|
|
1147
|
+
readonly epoch: Uint8Array;
|
|
1148
|
+
readonly epochKey: string;
|
|
1149
|
+
readonly memberBytes: readonly Uint8Array[];
|
|
1150
|
+
readonly memberStrs: readonly string[];
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Per-{@link CoordEngine} epoch-rotation bookkeeping (`cohort-topic-trust-anchor-rotation-production`).
|
|
1155
|
+
* Two roles, one small object:
|
|
1156
|
+
*
|
|
1157
|
+
* - **Producer** — {@link predecessor} is the identity of the *last published* cert; a publish whose
|
|
1158
|
+
* first `k − x` differ from it is a rotation, and the predecessor identity scopes the rotation `/sign`
|
|
1159
|
+
* round (its epoch is `prevEpoch`, its members are the outgoing cohort to collect from).
|
|
1160
|
+
* - **Endorser** — {@link membersAt} answers "was I a member of the cohort at `epoch`?" over a two-deep
|
|
1161
|
+
* observed-epoch history ({@link current} + {@link prior}), kept fresh by {@link observe} on every
|
|
1162
|
+
* cohort assembly. A request for an epoch past that window is refused (the rapid-double-rotation gap).
|
|
1163
|
+
*
|
|
1164
|
+
* The observed history (for endorsing) is distinct from `lastPublished` (for producing): a non-deciding
|
|
1165
|
+
* member endorses rotations it never published, so it cannot rely on its own publish history alone.
|
|
1166
|
+
*/
|
|
1167
|
+
class RotationState {
|
|
1168
|
+
private current: CohortIdentity | undefined;
|
|
1169
|
+
private prior: CohortIdentity | undefined;
|
|
1170
|
+
private lastPublished: CohortIdentity | undefined;
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Record the engine's current cohort identity, shifting the previous current into {@link prior} on an
|
|
1174
|
+
* epoch change. Cheap on the hot path: `build` is only invoked when `epochKey` actually changes (the
|
|
1175
|
+
* member set is unchanged within an epoch, so there is nothing to refresh).
|
|
1176
|
+
*/
|
|
1177
|
+
observe(epochKey: string, build: () => CohortIdentity): void {
|
|
1178
|
+
if (this.current?.epochKey === epochKey) {
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const next = build();
|
|
1182
|
+
this.prior = this.current;
|
|
1183
|
+
this.current = next;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** Mark `identity` as the most recently published cert's identity (the rotation chain predecessor). */
|
|
1187
|
+
recordPublished(identity: CohortIdentity): void {
|
|
1188
|
+
this.lastPublished = identity;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** The last-published identity (the predecessor a fresh rotation attests from), or `undefined`. */
|
|
1192
|
+
predecessor(): CohortIdentity | undefined {
|
|
1193
|
+
return this.lastPublished;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** The member peer-id strings observed under `epochKey` (current or prior), or `undefined`. */
|
|
1197
|
+
membersAt(epochKey: string): readonly string[] | undefined {
|
|
1198
|
+
if (this.current?.epochKey === epochKey) {
|
|
1199
|
+
return this.current.memberStrs;
|
|
1200
|
+
}
|
|
1201
|
+
if (this.prior?.epochKey === epochKey) {
|
|
1202
|
+
return this.prior.memberStrs;
|
|
1203
|
+
}
|
|
1204
|
+
return undefined;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Compose one {@link CoordEngine} bound to `servedCoord`. The cohort it threshold-signs / shards with
|
|
1210
|
+
* is the FRET assembly around `servedCoord` (not the node's own ring position). The promotion tier
|
|
1211
|
+
* inputs are coord-derived: `treeTier` is fixed at instantiation; `parentCoord` is
|
|
1212
|
+
* `coord_{d-1}(participantCoord, topicId)` (the shard's parent shares the prefix, so any participant
|
|
1213
|
+
* routed here yields the same parent); `childCohortCount` is `0` for the single-cohort milestone.
|
|
1214
|
+
*/
|
|
1215
|
+
function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, treeTier: number, participantCoord: Uint8Array): CoordEngine {
|
|
1216
|
+
const store = createRegistrationStore();
|
|
1217
|
+
// Epoch-rotation bookkeeping. `cohort()` observes every assembly so the endorser history stays fresh
|
|
1218
|
+
// (the gossip-cadence driver assembles each round); the producer reads `predecessor()` on publish.
|
|
1219
|
+
const rotationState = new RotationState();
|
|
1220
|
+
const identityOf = (view: { members: readonly Uint8Array[]; cohortEpoch: Uint8Array }): CohortIdentity => {
|
|
1221
|
+
const memberBytes = [...view.members];
|
|
1222
|
+
return {
|
|
1223
|
+
epoch: view.cohortEpoch,
|
|
1224
|
+
epochKey: bytesToB64url(view.cohortEpoch),
|
|
1225
|
+
memberBytes,
|
|
1226
|
+
memberStrs: memberBytes.map(bytesToPeerIdString),
|
|
1227
|
+
};
|
|
1228
|
+
};
|
|
1229
|
+
const cohort = (): CohortSnapshotView => {
|
|
1230
|
+
const view = ctx.cohortAround(servedCoord);
|
|
1231
|
+
rotationState.observe(bytesToB64url(view.cohortEpoch), () => identityOf(view));
|
|
1232
|
+
return view;
|
|
1233
|
+
};
|
|
1234
|
+
const localEpoch = (): Uint8Array => cohort().cohortEpoch;
|
|
1235
|
+
|
|
1236
|
+
// Inbound gossip is routed to this bus by its `coord`; the optional auth gate (live-signer mode) drops
|
|
1237
|
+
// a frame whose `fromMember` signature is bad or who is not a member of the cohort around THIS coord.
|
|
1238
|
+
const bus = createCohortGossipBus({
|
|
1239
|
+
transport: ctx.transport,
|
|
1240
|
+
store,
|
|
1241
|
+
coord: servedCoord,
|
|
1242
|
+
localEpoch,
|
|
1243
|
+
verifyInbound: ctx.verifyGossip === undefined ? undefined : (g): boolean => ctx.verifyGossip!(g, servedCoord),
|
|
1244
|
+
// Sibling-drain half of the topic-budget release: a topic whose participants are sharded onto a
|
|
1245
|
+
// sibling primary drains into this store as a gossip eviction (never this member's own TTL sweep),
|
|
1246
|
+
// so re-`touch` the budget down from the post-delete store count — mirroring the engine's `sweepStale`
|
|
1247
|
+
// re-touch. `topicBudget` is a forward `const` here, only read when this callback fires at merge time
|
|
1248
|
+
// (long after both are initialized). A no-op for a topic the budget does not hold (`touch` guards it).
|
|
1249
|
+
onRecordsEvicted: (topicIds): void => {
|
|
1250
|
+
for (const topicId of topicIds) {
|
|
1251
|
+
topicBudget.touch(topicId, store.directParticipants(topicId));
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
});
|
|
1255
|
+
const view = bus.view();
|
|
1256
|
+
const selfMember = bytesToB64url(ctx.selfMemberBytes);
|
|
1257
|
+
|
|
1258
|
+
// Per-touch replication delta queue: the renewal cohort side appends each served touch / TTL eviction
|
|
1259
|
+
// here; the next gossip round drains the batch into the broadcast frame (one round, not one per ping).
|
|
1260
|
+
const pending = createPendingDeltas();
|
|
1261
|
+
|
|
1262
|
+
// Per-coord threshold signers: each assembles a real k − x signature by signing locally and collecting
|
|
1263
|
+
// the rest of the cohort around THIS served coord over the `/sign` RPC. `membership` signs the cert;
|
|
1264
|
+
// `promotion` signs promote/demote notices (kind drives the dialed members' endorsement policy). In
|
|
1265
|
+
// key-less interim mode there is no key to sign self's chunk, so the signer is verify-only (the
|
|
1266
|
+
// publisher / promotion paths are simply not driven without a key).
|
|
1267
|
+
const makeCoordSigner = (kind: SignKind): CohortSigner => {
|
|
1268
|
+
if (ctx.privateKey === undefined) {
|
|
1269
|
+
return createCohortSigner(createVerifyOnlyThresholdCrypto(), ctx.minSigs);
|
|
1270
|
+
}
|
|
1271
|
+
const crypto = new FretCohortThresholdCrypto({
|
|
1272
|
+
kind,
|
|
1273
|
+
privateKey: ctx.privateKey,
|
|
1274
|
+
selfMember: ctx.selfMemberBytes,
|
|
1275
|
+
coord: (): RingCoord => servedCoord,
|
|
1276
|
+
cohortEpoch: localEpoch,
|
|
1277
|
+
cohortMembers: (): string[] => cohort().members.map(bytesToPeerIdString),
|
|
1278
|
+
dialSign: ctx.dialSign,
|
|
1279
|
+
});
|
|
1280
|
+
return createCohortSigner(crypto, ctx.minSigs);
|
|
1281
|
+
};
|
|
1282
|
+
const noticeSigner = makeCoordSigner("promotion");
|
|
1283
|
+
const membershipSigner = makeCoordSigner("membership");
|
|
1284
|
+
|
|
1285
|
+
// Cohort-side membership-cert publisher: threshold-signs a MembershipCertV1 over this coord's cohort
|
|
1286
|
+
// and serves it through the node's publish sink. Driven by the onStabilized / pumpMembership hooks.
|
|
1287
|
+
const membershipPublisher: MembershipCertPublisher = createMembershipCertPublisher({
|
|
1288
|
+
signer: membershipSigner,
|
|
1289
|
+
sink: ctx.publishSink,
|
|
1290
|
+
minSigs: ctx.minSigs,
|
|
1291
|
+
maxMessageBytes: ctx.maxBytes,
|
|
1292
|
+
});
|
|
1293
|
+
const snapshotAt = (now: number): CohortSnapshot => {
|
|
1294
|
+
const { members, cohortEpoch } = cohort();
|
|
1295
|
+
return { coord: servedCoord, cohortEpoch, members, stabilizedAt: now };
|
|
1296
|
+
};
|
|
1297
|
+
// Key-less interim mode has only a verify-only per-coord signer (its `assemble` rejects), so the
|
|
1298
|
+
// publish hooks must no-op rather than surface a rejected promise — matching the documented
|
|
1299
|
+
// "publisher paths are simply not driven without a key" contract. Without this guard a future
|
|
1300
|
+
// gossip-cadence driver iterating `registry.all()` would reject on every key-less engine.
|
|
1301
|
+
const canPublish = ctx.privateKey !== undefined;
|
|
1302
|
+
|
|
1303
|
+
// --- epoch-rotation attestation production (cohort-topic-trust-anchor-rotation-production) ---
|
|
1304
|
+
// The first `k − x` members of the cert's ascending order; a change across a publish is a rotation
|
|
1305
|
+
// (mirrors the publisher's own republish gate — same inputs, so the two agree on what is a rotation).
|
|
1306
|
+
const firstKx = (members: readonly Uint8Array[]): string[] =>
|
|
1307
|
+
[...members].sort(compareBytes).slice(0, ctx.minSigs).map(bytesToB64url);
|
|
1308
|
+
const firstKxChanged = (a: CohortIdentity, b: CohortIdentity): boolean =>
|
|
1309
|
+
!sameStringOrder(firstKx(a.memberBytes), firstKx(b.memberBytes));
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Threshold-sign the new cert's canonical payload under the **predecessor** cohort identity, producing the
|
|
1313
|
+
* `{ prevEpoch, rotationSig, rotationSigners }` attestation — or `undefined` if the predecessor quorum is
|
|
1314
|
+
* unreachable (mass churn / partition), in which case the caller publishes the rotation cert WITHOUT an
|
|
1315
|
+
* attestation (trust falls to the direct anchor / TOFU, no worse than a non-rotation publish). The `/sign`
|
|
1316
|
+
* round is scoped to the prior epoch's members (`kind: "rotation"`), so the endorsers are the genuinely
|
|
1317
|
+
* outgoing cohort. The payload is built through the SAME `membershipCertSignable` the publisher signs, so
|
|
1318
|
+
* the signature image matches exactly (the db-core chain check verifies `rotationSig` over it).
|
|
1319
|
+
*/
|
|
1320
|
+
const produceRotation = async (snapshot: CohortSnapshot, predecessor: CohortIdentity): Promise<RotationAttestation | undefined> => {
|
|
1321
|
+
const payload = membershipCertSigningPayload(membershipCertSignable(snapshot));
|
|
1322
|
+
const selfStr = bytesToPeerIdString(ctx.selfMemberBytes);
|
|
1323
|
+
const crypto = new FretCohortThresholdCrypto({
|
|
1324
|
+
kind: "rotation",
|
|
1325
|
+
privateKey: ctx.privateKey!, // canPublish guard: rotation only runs from a publish, which no-ops key-less
|
|
1326
|
+
selfMember: ctx.selfMemberBytes,
|
|
1327
|
+
coord: (): RingCoord => servedCoord,
|
|
1328
|
+
cohortEpoch: (): Uint8Array => predecessor.epoch, // prevEpoch — scopes the endorsement to the prior epoch
|
|
1329
|
+
cohortMembers: (): string[] => [...predecessor.memberStrs], // dial the OUTGOING cohort
|
|
1330
|
+
dialSign: ctx.dialSign,
|
|
1331
|
+
selfEligible: (): boolean => predecessor.memberStrs.includes(selfStr),
|
|
1332
|
+
});
|
|
1333
|
+
const signer = createCohortSigner(crypto, ctx.minSigs);
|
|
1334
|
+
try {
|
|
1335
|
+
const { thresholdSig, signers } = await signer.thresholdSign(payload);
|
|
1336
|
+
return { prevEpoch: predecessor.epoch, rotationSig: thresholdSig, rotationSigners: signers };
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
log("cohort-topic: rotation attestation skipped at coord %s — predecessor quorum unavailable: %o", bytesToB64url(servedCoord), err);
|
|
1339
|
+
return undefined;
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Publish (or refresh) this cohort's membership cert, attaching a rotation attestation when the first
|
|
1345
|
+
* `k − x` changed since the last publish. `refresh` selects the publisher path: `false` for a
|
|
1346
|
+
* stabilization event ({@link CoordEngine.onStabilized}), `true` for the periodic refresh
|
|
1347
|
+
* ({@link CoordEngine.pumpMembership}). A first-`k − x` change is a stabilization regardless of which hook
|
|
1348
|
+
* fired, so it routes through `onStabilized` (which republishes promptly on the change) carrying the
|
|
1349
|
+
* attestation; the `/sign` round runs only on that change, so it costs one round per rotation, never per
|
|
1350
|
+
* tick. Key-less interim mode no-ops (the verify-only signer cannot assemble).
|
|
1351
|
+
*/
|
|
1352
|
+
const publishMembership = async (now: number, refresh: boolean): Promise<MembershipCertV1 | undefined> => {
|
|
1353
|
+
if (!canPublish) {
|
|
1354
|
+
return undefined;
|
|
1355
|
+
}
|
|
1356
|
+
const snapshot = snapshotAt(now); // also observes the current identity (snapshotAt → cohort())
|
|
1357
|
+
const current = identityOf(snapshot);
|
|
1358
|
+
const predecessor = rotationState.predecessor();
|
|
1359
|
+
const rotating = predecessor !== undefined && firstKxChanged(predecessor, current);
|
|
1360
|
+
let published: MembershipCertV1 | undefined;
|
|
1361
|
+
if (rotating) {
|
|
1362
|
+
const rotation = await produceRotation(snapshot, predecessor!);
|
|
1363
|
+
published = await membershipPublisher.onStabilized(snapshot, now, rotation);
|
|
1364
|
+
} else {
|
|
1365
|
+
published = await (refresh ? membershipPublisher.tick(snapshot, now) : membershipPublisher.onStabilized(snapshot, now));
|
|
1366
|
+
}
|
|
1367
|
+
if (published !== undefined) {
|
|
1368
|
+
rotationState.recordPublished(current);
|
|
1369
|
+
ctx.onCertPublished?.(published);
|
|
1370
|
+
}
|
|
1371
|
+
return published;
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
const willingness = createWillingnessCheck({
|
|
1375
|
+
barometer: ctx.barometer,
|
|
1376
|
+
view,
|
|
1377
|
+
selfMember,
|
|
1378
|
+
primaryTopicCount: (tier: Tier): number => countPrimaryTopics(store, ctx.selfMemberBytes, tier),
|
|
1379
|
+
config: { cohortSize: ctx.wantK },
|
|
1380
|
+
});
|
|
1381
|
+
const traffic = createTrafficCounters({ view, store, selfMember });
|
|
1382
|
+
const promotion = createPromotionLifecycle({
|
|
1383
|
+
store,
|
|
1384
|
+
loadBucket: (topicId: Uint8Array): number => ctx.barometer.bucket(tierOfTopic(store, topicId)),
|
|
1385
|
+
// Single-cohort milestone: a tier-0 cohort with no children. Child-cohort tracking is a follow-on.
|
|
1386
|
+
childCohortCount: (): number => 0,
|
|
1387
|
+
treeTier: (): number => treeTier,
|
|
1388
|
+
// `coord_{d-1}(P, topicId)`; never invoked at the root (demotion is gated on `treeTier > 0`), so
|
|
1389
|
+
// the `d = 0` branch (clamped to `coord_0`) is a well-formed placeholder that the lifecycle skips.
|
|
1390
|
+
parentCoord: (topicId: Uint8Array): Uint8Array => ctx.addressing.coord(Math.max(0, treeTier - 1), participantCoord, topicId),
|
|
1391
|
+
cohortEpoch: localEpoch,
|
|
1392
|
+
signer: noticeSigner,
|
|
1393
|
+
// Production defaults (cap_promote = 64, …) unless the host was given a promotion override — the
|
|
1394
|
+
// live-tier e2e lowers `capPromote` to drive promotion with a small participant count. The
|
|
1395
|
+
// coord-derived inputs above (treeTier / childCohortCount / parentCoord) are never overridden.
|
|
1396
|
+
config: ctx.promotionConfig ?? { capPromote: undefined },
|
|
1397
|
+
});
|
|
1398
|
+
// Cold-start forwarder → parent registration (gap 7). A freshly-instantiated tier-`d > 0` forwarder
|
|
1399
|
+
// registers with its tier-`(d − 1)` parent cohort at `parentCoord` so the parent counts it as a child;
|
|
1400
|
+
// the ColdStartManager holds the forwarder in `awaiting_parent` (accepts participants, holds
|
|
1401
|
+
// parent-involving ops) until this resolves. This supplies the TRANSPORT: route a forwarder-link frame
|
|
1402
|
+
// to `parentCoord` over the same `RouteAndMaybeAct` path a participant register rides. A resolved
|
|
1403
|
+
// round-trip is the parent ack (flip to `serving`); a rejected/timed-out route leaves the forwarder
|
|
1404
|
+
// `awaiting_parent` for a later retry and never crashes the instantiating register (cold-start fires
|
|
1405
|
+
// this fire-and-forget). The parent-side child-cohort RECORDING (`childCohortCount`, a dedicated
|
|
1406
|
+
// child-link frame) is a follow-on (`tickets/backlog/cohort-topic-parent-child-link`); the
|
|
1407
|
+
// single-tier-0 milestone has no parent (the root serves immediately), so a unit test exercises this.
|
|
1408
|
+
const coldStart = createColdStartManager({
|
|
1409
|
+
parentRegistrar: {
|
|
1410
|
+
registerWithParent: (topicId: Uint8Array, parentCoord: Uint8Array, tier: number, opTier?: number): Promise<void> =>
|
|
1411
|
+
registerForwarderWithParent(ctx, { topicId, parentCoord, treeTier: tier, opTier, participantCoord }),
|
|
1412
|
+
},
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
// Per-coord anti-DoS guards (gap 6): each CoordEngine owns its own set — a rate-limit budget / replay
|
|
1416
|
+
// window / topic budget for coord A is independent of coord B. The bootstrap-evidence policy
|
|
1417
|
+
// (`ctx.bootstrapEvidence`) is node-level and shared by design.
|
|
1418
|
+
const rateLimiter = createRegisterRateLimiter(ctx.antiDos.rateLimiter);
|
|
1419
|
+
// The read-only lookup-probe path gets its OWN per-coord rate limiter (same config, separate budget),
|
|
1420
|
+
// so a probe flood cannot exhaust a participant's register budget at this coord, or vice-versa.
|
|
1421
|
+
const probeRateLimiter = createRegisterRateLimiter(ctx.antiDos.rateLimiter);
|
|
1422
|
+
const replayGuard = createCorrelationReplayGuard(ctx.antiDos.replayGuard);
|
|
1423
|
+
const topicBudget = createTopicBudget(ctx.antiDos.topicBudget);
|
|
1424
|
+
const renewal = createRenewalCohortSide({
|
|
1425
|
+
store,
|
|
1426
|
+
self: ctx.selfMemberBytes,
|
|
1427
|
+
slots: ctx.slots,
|
|
1428
|
+
cohort,
|
|
1429
|
+
gossip: {
|
|
1430
|
+
// Per-touch replication, batched to one gossip round: a served ping/re-attach queues the touched
|
|
1431
|
+
// record; the next round drains it so cohort members converge on the active set + assignments.
|
|
1432
|
+
touch: (rec): void => pending.touch(rec),
|
|
1433
|
+
// A TTL sweep eviction is gossiped so siblings drop the dead record (convergence on eviction).
|
|
1434
|
+
evicted: (rec): void => pending.evicted(rec),
|
|
1435
|
+
},
|
|
1436
|
+
verifyParticipantSig: ctx.verifyParticipantSig,
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
const engine = createCohortMemberEngine({
|
|
1440
|
+
self: ctx.selfMemberBytes,
|
|
1441
|
+
profile: ctx.profile,
|
|
1442
|
+
hash: ctx.hash,
|
|
1443
|
+
store,
|
|
1444
|
+
slots: ctx.slots,
|
|
1445
|
+
willingness,
|
|
1446
|
+
promotion,
|
|
1447
|
+
coldStart,
|
|
1448
|
+
traffic,
|
|
1449
|
+
renewal,
|
|
1450
|
+
cohort,
|
|
1451
|
+
quorumWilling: (tier: Tier): boolean => ctx.profile.willingTiers.has(tier),
|
|
1452
|
+
// Anti-DoS guards (gap 6): per-coord rate/replay/budget; node-level bootstrap-evidence policy.
|
|
1453
|
+
rateLimiter,
|
|
1454
|
+
// Dedicated probe-path rate limiter (independent budget from `rateLimiter`).
|
|
1455
|
+
probeRateLimiter,
|
|
1456
|
+
replayGuard,
|
|
1457
|
+
topicBudget,
|
|
1458
|
+
bootstrapEvidence: ctx.bootstrapEvidence,
|
|
1459
|
+
verifyRegisterSig: ctx.verifyRegisterSig,
|
|
1460
|
+
// Admission-time replication: enqueue the just-admitted record so siblings hold a replica before the
|
|
1461
|
+
// participant's first renewal touch (closes the accept→first-touch durability window). Same queue +
|
|
1462
|
+
// last-writer-wins as the renewal `gossip.touch`.
|
|
1463
|
+
onAdmit: (rec): void => pending.touch(rec),
|
|
1464
|
+
// A promotion notice signed on an arrival is broadcast to the cohort around this served coord
|
|
1465
|
+
// (and the parent for a demotion). The engine only knows the notice; the host adds the coord.
|
|
1466
|
+
onNotice: (notice): void => ctx.broadcastNotice?.(notice, servedCoord),
|
|
1467
|
+
log,
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
/** Distinct topics this engine currently holds state for (the gossip-summary / demotion iteration set). */
|
|
1471
|
+
const residentTopics = (): Uint8Array[] => {
|
|
1472
|
+
const byKey = new Map<string, Uint8Array>();
|
|
1473
|
+
for (const rec of store.listAll()) {
|
|
1474
|
+
byKey.set(bytesToB64url(rec.topicId), rec.topicId);
|
|
1475
|
+
}
|
|
1476
|
+
return [...byKey.values()];
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
// One gossip round: sweep stale records (firing the `evicted` deltas), freeze each resident topic's
|
|
1480
|
+
// traffic summary, drain the touch/evicted deltas, then assemble + sign + broadcast the frame. Idle
|
|
1481
|
+
// empty engines (no topics, no deltas) build no frame and skip the broadcast.
|
|
1482
|
+
const gossipRound = async (now: number): Promise<CohortGossipV1 | undefined> => {
|
|
1483
|
+
engine.sweepStale(now);
|
|
1484
|
+
const topicSummaries = residentTopics().map((topicId) =>
|
|
1485
|
+
toCohortTopicSummary(topicId, traffic.publish(topicId, now), {
|
|
1486
|
+
tier: tierOfTopic(store, topicId),
|
|
1487
|
+
directParticipants: store.directParticipants(topicId),
|
|
1488
|
+
promoted: promotion.isPromoted(topicId),
|
|
1489
|
+
// Single-cohort milestone: no child cohorts tracked. Child-cohort tracking is a follow-on.
|
|
1490
|
+
childCohortCount: 0,
|
|
1491
|
+
}),
|
|
1492
|
+
);
|
|
1493
|
+
const { records, evicted } = pending.drain();
|
|
1494
|
+
const g = buildCohortGossip({
|
|
1495
|
+
fromMember: selfMember,
|
|
1496
|
+
coord: bytesToB64url(servedCoord),
|
|
1497
|
+
cohortEpoch: bytesToB64url(localEpoch()),
|
|
1498
|
+
profile: ctx.profile,
|
|
1499
|
+
barometer: ctx.barometer,
|
|
1500
|
+
windowSeconds: DEFAULT_TRAFFIC_WINDOW_SECONDS,
|
|
1501
|
+
topicSummaries,
|
|
1502
|
+
records,
|
|
1503
|
+
evicted,
|
|
1504
|
+
timestamp: now,
|
|
1505
|
+
});
|
|
1506
|
+
if (g === undefined) {
|
|
1507
|
+
return undefined;
|
|
1508
|
+
}
|
|
1509
|
+
if (ctx.signGossip !== undefined) {
|
|
1510
|
+
g.signature = await ctx.signGossip(g);
|
|
1511
|
+
}
|
|
1512
|
+
bus.broadcast(g);
|
|
1513
|
+
return g;
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
// Time-driven demotion across resident topics; any returned notice is broadcast to the cohort (and the
|
|
1517
|
+
// parent coord) via the same path a promotion uses. Skipped without a key (verify-only signer can't
|
|
1518
|
+
// assemble); for the single-cohort tier-0 milestone the lifecycle never demotes (the root has no parent).
|
|
1519
|
+
const demotionTick = async (now: number): Promise<void> => {
|
|
1520
|
+
if (!canPublish) {
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
for (const topicId of residentTopics()) {
|
|
1524
|
+
let notice: DemotionNoticeV1 | undefined;
|
|
1525
|
+
try {
|
|
1526
|
+
notice = await promotion.maybeDemote(topicId, now);
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
log("cohort-topic: demotion sign/broadcast failed for topic %s: %o", bytesToB64url(topicId), err);
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
if (notice !== undefined) {
|
|
1532
|
+
ctx.broadcastNotice?.(notice, servedCoord);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
return {
|
|
1538
|
+
servedCoord,
|
|
1539
|
+
treeTier,
|
|
1540
|
+
engine,
|
|
1541
|
+
cohort,
|
|
1542
|
+
cohortIdentityAt: (epoch: Uint8Array): readonly string[] | undefined => rotationState.membersAt(bytesToB64url(epoch)),
|
|
1543
|
+
hasState: (): boolean => store.listAll().length > 0,
|
|
1544
|
+
holds: (topicId: Uint8Array, participantId: Uint8Array): boolean =>
|
|
1545
|
+
store.getByParticipant(topicId, participantId) !== undefined,
|
|
1546
|
+
records: (topicId: Uint8Array): readonly RegistrationRecord[] => store.listByTopic(topicId),
|
|
1547
|
+
topicTraffic: (topicId: Uint8Array): TopicTrafficV1 => traffic.snapshot(topicId),
|
|
1548
|
+
cohortView: (): CohortView => view,
|
|
1549
|
+
servesTopic: (topicId: Uint8Array): boolean =>
|
|
1550
|
+
store.directParticipants(topicId) > 0 || coldStart.get(topicId) !== undefined,
|
|
1551
|
+
budgetHasTopic: (topicId: Uint8Array): boolean => topicBudget.has(topicId),
|
|
1552
|
+
budgetParticipantCount: (topicId: Uint8Array): number | undefined => topicBudget.participantCount(topicId),
|
|
1553
|
+
forwarder: (topicId: Uint8Array): Forwarder | undefined => coldStart.get(topicId),
|
|
1554
|
+
isPromoted: (topicId: Uint8Array): boolean => promotion.isPromoted(topicId),
|
|
1555
|
+
applyPromotionNotice: (notice, now): void => promotion.applyPromotionNotice(notice, now),
|
|
1556
|
+
applyDemotionNotice: (notice, now): void => promotion.applyDemotionNotice(notice, now),
|
|
1557
|
+
onStabilized: (now: number): Promise<MembershipCertV1 | undefined> => publishMembership(now, false),
|
|
1558
|
+
pumpMembership: (now: number): Promise<MembershipCertV1 | undefined> => publishMembership(now, true),
|
|
1559
|
+
gossipRound,
|
|
1560
|
+
demotionTick,
|
|
1561
|
+
close: (): void => bus.close(),
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Resolve an inbound `RenewV1` to the coord engine holding its record and run the renewal. A `RenewV1`
|
|
1567
|
+
* carries no `treeTier`, so the held record — not a recomputed coord — names the cohort. If no engine
|
|
1568
|
+
* on this host holds it (cross-node renewal, post-restart eviction, or replication lag), reply
|
|
1569
|
+
* `unknown_registration` so the participant's failover loop tries its backups and ultimately re-runs
|
|
1570
|
+
* the `d_max` lookup (§TTL and renewal) — never throw.
|
|
1571
|
+
*/
|
|
1572
|
+
export function resolveRenew(registry: CoordRegistry, renew: RenewV1, now: number): RenewReplyV1 {
|
|
1573
|
+
const topicId = b64urlToBytes(renew.topicId);
|
|
1574
|
+
const participantId = b64urlToBytes(renew.participantId);
|
|
1575
|
+
const holder = registry.findHolder(topicId, participantId);
|
|
1576
|
+
if (holder === undefined) {
|
|
1577
|
+
return { v: 1, result: "unknown_registration" };
|
|
1578
|
+
}
|
|
1579
|
+
return holder.engine.handleRenew(renew, now);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// --- intra-cohort sign endorsement ---
|
|
1583
|
+
|
|
1584
|
+
/** Dependencies for the `/sign` endorsement policy ({@link handleSignRequest}). */
|
|
1585
|
+
export interface SignEndorsementDeps {
|
|
1586
|
+
/** The node's libp2p key (signs the endorsement). Absent → every request is refused (no key to sign with). */
|
|
1587
|
+
readonly privateKey: PrivateKey | undefined;
|
|
1588
|
+
/** Self's dialable member id (UTF-8 peer-id string bytes). */
|
|
1589
|
+
readonly selfMember: Uint8Array;
|
|
1590
|
+
/** Cohort member peer-id strings around `coord` (self included). */
|
|
1591
|
+
readonly cohortMembersAround: (coord: RingCoord) => string[];
|
|
1592
|
+
/** Current cohort epoch (raw bytes) for `coord`. */
|
|
1593
|
+
readonly currentEpoch: (coord: RingCoord) => Uint8Array;
|
|
1594
|
+
/**
|
|
1595
|
+
* For a `"rotation"` request: the cohort member peer-id strings this node served under `epoch` (the
|
|
1596
|
+
* predecessor epoch carried as `request.cohortEpoch`), or `undefined` if it tracked no such epoch. The
|
|
1597
|
+
* host wires it to the served coord's {@link CoordEngine.cohortIdentityAt}; absent → rotation
|
|
1598
|
+
* endorsement is unavailable (a node with no per-coord rotation history refuses the hand-off).
|
|
1599
|
+
*/
|
|
1600
|
+
readonly priorCohortMembersAt?: (coord: RingCoord, epoch: Uint8Array) => readonly string[] | undefined;
|
|
1601
|
+
/**
|
|
1602
|
+
* The endorser's own canonical `MembershipCertV1` signable fields for `coord` at its **current** epoch,
|
|
1603
|
+
* re-derived from its own cohort snapshot ({@link membershipCertSignable} with `stabilizedAt`
|
|
1604
|
+
* omitted/ignored). Binds a `membership` endorsement to the endorser's independent view so a cohort
|
|
1605
|
+
* insider cannot collect honest signatures over a cert the cohort never agreed to (falsified `members`
|
|
1606
|
+
* or `cohortCoord`). `cohortEpoch` here MUST equal `bytesToB64url(currentEpoch(coord))`; `members` is the
|
|
1607
|
+
* ascending-sorted base64url cohort set (the cert / sharding order). Absent → a `membership` request is
|
|
1608
|
+
* refused (no view to bind the cert against). See `cohort-topic-sign-endorsement-payload-binding`.
|
|
1609
|
+
*/
|
|
1610
|
+
readonly expectedMembershipFields?: (coord: RingCoord) => { cohortCoord: string; cohortEpoch: string; members: string[] };
|
|
1611
|
+
/**
|
|
1612
|
+
* Wall clock (ms) for the `stabilizedAt` far-future sanity bound on a `membership` payload. The host
|
|
1613
|
+
* wires {@link Date.now}; tests inject a fixed clock. Absent → the far-future bound is skipped (only
|
|
1614
|
+
* finiteness is enforced), keeping minimal / key-less composition working.
|
|
1615
|
+
*/
|
|
1616
|
+
readonly now?: () => number;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/** Tolerated future skew (ms) for a `membership` payload's `stabilizedAt` — a value beyond this is refused. */
|
|
1620
|
+
const SIGN_STABILIZED_AT_SKEW_MS = 5_000;
|
|
1621
|
+
|
|
1622
|
+
/** The canonical signable-image tag a non-`rotation` {@link SignKind} must carry (binds tag ↔ kind). */
|
|
1623
|
+
const SIGNABLE_IMAGE_TAG: Record<Exclude<SignKind, "rotation">, string> = {
|
|
1624
|
+
membership: "MembershipCertV1",
|
|
1625
|
+
promotion: "PromotionNoticeV1",
|
|
1626
|
+
demotion: "DemotionNoticeV1",
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* Decode a `/sign` payload's canonical signable array image — `utf8(JSON.stringify([...]))` produced by
|
|
1631
|
+
* `sig/payloads.ts`. Returns the decoded array, or `undefined` when the bytes are not base64url of a JSON
|
|
1632
|
+
* array. NOTE: the payload is a raw signable image, **not** a `CohortMessageV1`, so it is decoded with
|
|
1633
|
+
* `JSON.parse`, never `decodeCohortMessage`.
|
|
1634
|
+
*/
|
|
1635
|
+
function decodeSignableImage(payloadB64: string): unknown[] | undefined {
|
|
1636
|
+
try {
|
|
1637
|
+
const decoded = JSON.parse(new TextDecoder().decode(b64urlToBytes(payloadB64))) as unknown;
|
|
1638
|
+
return Array.isArray(decoded) ? decoded : undefined;
|
|
1639
|
+
} catch {
|
|
1640
|
+
return undefined;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Deep-equal of an unknown payload field against the endorser's own ascending-sorted base64url member
|
|
1646
|
+
* list. Guards the `unknown` (a non-array payload field → not a match) before delegating to the shared
|
|
1647
|
+
* ordered-string-array equality; element-wise `!==` makes a non-string entry a mismatch.
|
|
1648
|
+
*/
|
|
1649
|
+
function sameMemberList(image: unknown, expected: readonly string[]): boolean {
|
|
1650
|
+
return Array.isArray(image) && sameStringOrder(image, expected);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* The `/sign` endorsement policy: decide whether to endorse a {@link SignRequestV1} and, if so, return
|
|
1655
|
+
* this node's Ed25519 peer-key signature over the **exact** request payload. A member endorses only when
|
|
1656
|
+
* both it and the requester are members of the cohort around `coord` under the request's epoch, **and**
|
|
1657
|
+
* the payload bytes re-derive to something this node independently agrees to attest — so it never signs
|
|
1658
|
+
* for outsiders and never blindly signs requester-supplied bytes. It still signs the exact image (no
|
|
1659
|
+
* re-canonicalization), so the assembled signature verifies against what the requester collected.
|
|
1660
|
+
*
|
|
1661
|
+
* **Payload binding (cohort-topic-sign-endorsement-payload-binding).** The cohort + wire-epoch gates never
|
|
1662
|
+
* inspect `request.payload`; without binding it an insider could collect honest signatures over a cert the
|
|
1663
|
+
* cohort never agreed to. After those gates the endorser decodes the canonical signable image
|
|
1664
|
+
* (`sig/payloads.ts` — a `JSON.parse`d array, NOT a `CohortMessageV1`) and refuses unless:
|
|
1665
|
+
*
|
|
1666
|
+
* - **all kinds** — the image tag matches the kind (`membership`→`MembershipCertV1`, etc.) and the
|
|
1667
|
+
* payload-internal `cohortEpoch` equals this node's current epoch for `coord`; and
|
|
1668
|
+
* - **`membership`** — `cohortCoord`, the full `members` list (deep-equal to the endorser's own ascending
|
|
1669
|
+
* re-derived set), and a finite, not-far-future `stabilizedAt` all match its independent view
|
|
1670
|
+
* ({@link SignEndorsementDeps.expectedMembershipFields}). Because epoch = H(members) in this host, the
|
|
1671
|
+
* members and internal-epoch checks are mutually reinforcing: a forged member list cannot also carry the
|
|
1672
|
+
* honest epoch. The participant verifier still independently re-checks `signers ⊆ cert.members`.
|
|
1673
|
+
*
|
|
1674
|
+
* The kind-specific **hot/cold** refinement for `promotion` / `demotion` (the endorser additionally
|
|
1675
|
+
* requiring its own replicated `directParticipants` to be hot / cold) remains deferred: it needs a
|
|
1676
|
+
* per-topic binding the `(payload, minSigs)` port can't carry and gossip record replication that is still
|
|
1677
|
+
* interim. Parked in `cohort-topic-sign-endorsement-hotcold-refinement` (backlog).
|
|
1678
|
+
*
|
|
1679
|
+
* **`"rotation"` (epoch hand-off).** A rotation request carries the **prior** epoch as `cohortEpoch` and
|
|
1680
|
+
* asks the outgoing cohort to sign the *successor* cert. The gate therefore checks **prior**-epoch
|
|
1681
|
+
* membership instead of current: the endorser must have served the cohort at `prevEpoch`, and the
|
|
1682
|
+
* requester must have been a member of that prior cohort too (the genuinely outgoing set). The verifier
|
|
1683
|
+
* still independently re-checks `rotationSigners ⊆ predecessor-cert.members`, so this gate is the
|
|
1684
|
+
* load-shedding sanity check, not the trust root. See `cohort-topic-trust-anchor-rotation-production`.
|
|
1685
|
+
*/
|
|
1686
|
+
export async function handleSignRequest(request: SignRequestV1, fromPeerStr: string, deps: SignEndorsementDeps): Promise<SignReplyV1> {
|
|
1687
|
+
if (deps.privateKey === undefined) {
|
|
1688
|
+
return { v: 1, refused: true, reason: "node has no signing key" };
|
|
1689
|
+
}
|
|
1690
|
+
const coord = b64urlToBytes(request.coord);
|
|
1691
|
+
const selfStr = bytesToPeerIdString(deps.selfMember);
|
|
1692
|
+
|
|
1693
|
+
if (request.kind === "rotation") {
|
|
1694
|
+
// Prior-epoch gate: endorse a hand-off only from an epoch THIS node was a member of, and only for a
|
|
1695
|
+
// requester that was a member of that same prior cohort. `request.cohortEpoch` IS the prevEpoch.
|
|
1696
|
+
const prevEpoch = b64urlToBytes(request.cohortEpoch);
|
|
1697
|
+
const priorMembers = deps.priorCohortMembersAt?.(coord, prevEpoch);
|
|
1698
|
+
if (priorMembers === undefined) {
|
|
1699
|
+
return { v: 1, refused: true, reason: "not a member of the prior cohort at prevEpoch" };
|
|
1700
|
+
}
|
|
1701
|
+
if (!priorMembers.includes(selfStr)) {
|
|
1702
|
+
return { v: 1, refused: true, reason: "self not in the prior cohort" };
|
|
1703
|
+
}
|
|
1704
|
+
if (!priorMembers.includes(fromPeerStr)) {
|
|
1705
|
+
return { v: 1, refused: true, reason: "requester not in the prior cohort" };
|
|
1706
|
+
}
|
|
1707
|
+
// Structural sanity only: a rotation carries the SUCCESSOR cert image. Full successor re-derivation is
|
|
1708
|
+
// out of scope here (the endorser is the OUTGOING cohort and may not know the successor member set), so
|
|
1709
|
+
// the prior-epoch gate stays the trust check — but reject a payload that is not even a MembershipCertV1
|
|
1710
|
+
// image so the gate cannot be tricked into signing junk bytes. See the rotation follow-on note.
|
|
1711
|
+
const rotImage = decodeSignableImage(request.payload);
|
|
1712
|
+
if (rotImage === undefined || rotImage[0] !== SIGNABLE_IMAGE_TAG.membership) {
|
|
1713
|
+
return { v: 1, refused: true, reason: "rotation payload is not a MembershipCertV1 image" };
|
|
1714
|
+
}
|
|
1715
|
+
const signature = await signPeer(deps.privateKey, b64urlToBytes(request.payload));
|
|
1716
|
+
return { v: 1, signer: bytesToB64url(deps.selfMember), signature: bytesToB64url(signature) };
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const members = deps.cohortMembersAround(coord);
|
|
1720
|
+
if (!members.includes(selfStr)) {
|
|
1721
|
+
return { v: 1, refused: true, reason: "not a cohort member for coord" };
|
|
1722
|
+
}
|
|
1723
|
+
if (!members.includes(fromPeerStr)) {
|
|
1724
|
+
return { v: 1, refused: true, reason: "requester not in cohort" };
|
|
1725
|
+
}
|
|
1726
|
+
if (!bytesEqual(b64urlToBytes(request.cohortEpoch), deps.currentEpoch(coord))) {
|
|
1727
|
+
return { v: 1, refused: true, reason: "cohort epoch mismatch" };
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// --- payload binding: re-derive what we are willing to attest and refuse anything that does not match ---
|
|
1731
|
+
// The wire-field gates above never inspect `request.payload`; without this an insider could collect honest
|
|
1732
|
+
// signatures over a cert the cohort never agreed to (falsified members / kind-mismatched bytes).
|
|
1733
|
+
const image = decodeSignableImage(request.payload);
|
|
1734
|
+
if (image === undefined) {
|
|
1735
|
+
return { v: 1, refused: true, reason: "payload is not a decodable signable image" };
|
|
1736
|
+
}
|
|
1737
|
+
// All kinds — bind tag ↔ kind (a `membership` request must carry a MembershipCertV1 image, closing the
|
|
1738
|
+
// kind-mismatch hole where a kind-agnostic threshold blob verifies for whatever the bytes decode to).
|
|
1739
|
+
const expectedTag = SIGNABLE_IMAGE_TAG[request.kind];
|
|
1740
|
+
if (image[0] !== expectedTag) {
|
|
1741
|
+
return { v: 1, refused: true, reason: `payload kind tag mismatch (expected ${expectedTag})` };
|
|
1742
|
+
}
|
|
1743
|
+
// All kinds — bind the payload-internal `cohortEpoch` to our own current epoch (closes the falsified-internal
|
|
1744
|
+
// -epoch hole, for promotion / demotion too). It is `image[2]` for a MembershipCertV1 image and the last
|
|
1745
|
+
// element for promotion / demotion (see `sig/payloads.ts`).
|
|
1746
|
+
const currentEpochB64 = bytesToB64url(deps.currentEpoch(coord));
|
|
1747
|
+
const embeddedEpoch = request.kind === "membership" ? image[2] : image[image.length - 1];
|
|
1748
|
+
if (embeddedEpoch !== currentEpochB64) {
|
|
1749
|
+
return { v: 1, refused: true, reason: "payload cohortEpoch does not match endorser view" };
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
if (request.kind === "membership") {
|
|
1753
|
+
// The core fix: bind coord + members + stabilizedAt to the endorser's independently re-derived view.
|
|
1754
|
+
// Because epoch = H(members) in this host, a forged member list cannot also carry the honest epoch — the
|
|
1755
|
+
// embedded-epoch gate above and this members gate are mutually reinforcing.
|
|
1756
|
+
const expected = deps.expectedMembershipFields?.(coord);
|
|
1757
|
+
if (expected === undefined) {
|
|
1758
|
+
return { v: 1, refused: true, reason: "no membership view to bind the cert against" };
|
|
1759
|
+
}
|
|
1760
|
+
if (image[1] !== expected.cohortCoord) {
|
|
1761
|
+
return { v: 1, refused: true, reason: "payload cohortCoord does not match endorser view" };
|
|
1762
|
+
}
|
|
1763
|
+
if (!sameMemberList(image[3], expected.members)) {
|
|
1764
|
+
return { v: 1, refused: true, reason: "payload members do not match endorser view" };
|
|
1765
|
+
}
|
|
1766
|
+
const stabilizedAt = image[4];
|
|
1767
|
+
if (typeof stabilizedAt !== "number" || !Number.isFinite(stabilizedAt)) {
|
|
1768
|
+
return { v: 1, refused: true, reason: "payload stabilizedAt is not a finite number" };
|
|
1769
|
+
}
|
|
1770
|
+
if (deps.now !== undefined && stabilizedAt > deps.now() + SIGN_STABILIZED_AT_SKEW_MS) {
|
|
1771
|
+
return { v: 1, refused: true, reason: "payload stabilizedAt is far-future" };
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
const signature = await signPeer(deps.privateKey, b64urlToBytes(request.payload));
|
|
1776
|
+
return { v: 1, signer: bytesToB64url(deps.selfMember), signature: bytesToB64url(signature) };
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// --- inbound promote-protocol notices (verify + apply) ---
|
|
1780
|
+
|
|
1781
|
+
/** A decoded `promote`-protocol frame, tagged by which notice it is. */
|
|
1782
|
+
export type InboundNotice =
|
|
1783
|
+
| { readonly kind: "promotion"; readonly notice: PromotionNoticeV1 }
|
|
1784
|
+
| { readonly kind: "demotion"; readonly notice: DemotionNoticeV1 };
|
|
1785
|
+
|
|
1786
|
+
/** Outcome of {@link verifyAndApplyNotice}. */
|
|
1787
|
+
export type NoticeOutcome = "applied" | "untrusted" | "dropped";
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Outcome of {@link handleInboundNotice} — the {@link NoticeOutcome}s plus the cheap pre-verify drops the
|
|
1791
|
+
* anti-abuse gate adds before any signature work runs:
|
|
1792
|
+
*
|
|
1793
|
+
* - `"undecodable"` — the frame is neither a promotion nor a demotion notice.
|
|
1794
|
+
* - `"rate-limited"` — the dialing `(peer, topic)` is over its `register_rate_per_peer` ceiling.
|
|
1795
|
+
* - `"stale"` — the notice's `effectiveAt` is at or below the last *applied* notice for its
|
|
1796
|
+
* `(topic, tier)` (a replay / out-of-order frame); dropped before `verifyMessage`.
|
|
1797
|
+
*/
|
|
1798
|
+
export type InboundNoticeResult = NoticeOutcome | "undecodable" | "rate-limited" | "stale";
|
|
1799
|
+
|
|
1800
|
+
/**
|
|
1801
|
+
* Node-level anti-abuse state for the `promote` handler (`cohort-topic-promote-handler-verify-amplification`).
|
|
1802
|
+
* The handler is node-level (one per node, not per coord), so unlike the per-{@link CoordEngine} register-path
|
|
1803
|
+
* guards it owns its own instances here.
|
|
1804
|
+
*/
|
|
1805
|
+
export interface PromoteGate {
|
|
1806
|
+
/**
|
|
1807
|
+
* Per-`(peer, topic)` inbound-notice rate limiter (reuses the register-path limiter). A peer streaming
|
|
1808
|
+
* forged notices at one topic is dropped once it exceeds the ceiling, before any verify / membership work.
|
|
1809
|
+
*/
|
|
1810
|
+
readonly rateLimiter: RegisterRateLimiter;
|
|
1811
|
+
/**
|
|
1812
|
+
* Per-`(topicId, tier)` high-water of the last *applied* notice's `effectiveAt`. A notice at or below the
|
|
1813
|
+
* water is a replay / out-of-order frame and is dropped before verification. Updated **only** on an
|
|
1814
|
+
* `"applied"` outcome (never on an unverified frame), so a forged notice carrying `effectiveAt = Infinity`
|
|
1815
|
+
* cannot poison the water and lock out legitimate notices.
|
|
1816
|
+
*
|
|
1817
|
+
* **Bounded.** An {@link LruMap} capped at {@link PROMOTE_HIGHWATER_MAX_KEYS} so the retain-forever shape
|
|
1818
|
+
* cannot leak on a long-lived node. Unlike the limiter this is *not* attacker-growable (it is written only
|
|
1819
|
+
* on an `"applied"` outcome, which needs a verified `≥ minSigs` cohort signature — reads of forged
|
|
1820
|
+
* `topicId`s via `.get` create nothing), so it never evicts under legitimate load; the cap is the
|
|
1821
|
+
* belt-and-suspenders bound. Evicting an entry is safe: the engine's {@link PromotionLifecycle} is
|
|
1822
|
+
* independently idempotent and `effectiveAt`-ordered (`PromotionState.lastEffectiveAt`), so an
|
|
1823
|
+
* evicted-then-replayed older notice re-verifies (one bounded, rate-capped `verifyMessage`) and then
|
|
1824
|
+
* **no-ops at the engine** rather than (re-)applying. Water absence only *opens* the gate, never closes it.
|
|
1825
|
+
*/
|
|
1826
|
+
readonly highWater: LruMap<string, number>;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Hard cap on tracked `(topicId, tier)` high-water entries; the least-recently-touched are evicted beyond
|
|
1831
|
+
* this. A modest bound is plenty — only verified applies grow the map, so it never evicts under legitimate
|
|
1832
|
+
* load — but it caps the otherwise retain-forever shape on a long-lived node.
|
|
1833
|
+
*/
|
|
1834
|
+
export const PROMOTE_HIGHWATER_MAX_KEYS = 8192;
|
|
1835
|
+
|
|
1836
|
+
/** Build the default {@link PromoteGate} from the (optional) anti-DoS rate-limiter config. */
|
|
1837
|
+
export function createPromoteGate(rateLimiterConfig?: RegisterRateLimiterConfig): PromoteGate {
|
|
1838
|
+
return { rateLimiter: createRegisterRateLimiter(rateLimiterConfig), highWater: new LruMap<string, number>(PROMOTE_HIGHWATER_MAX_KEYS) };
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/**
|
|
1842
|
+
* The slice of a {@link CoordEngine} the inbound notice path needs: the cohort coord the signers should
|
|
1843
|
+
* belong to (for verification) and the apply hooks. {@link CoordEngine} satisfies this; tests can pass a
|
|
1844
|
+
* minimal stand-in.
|
|
1845
|
+
*/
|
|
1846
|
+
export interface NoticeApplyTarget {
|
|
1847
|
+
readonly servedCoord: RingCoord;
|
|
1848
|
+
applyPromotionNotice(notice: PromotionNoticeV1, now: number): void;
|
|
1849
|
+
applyDemotionNotice(notice: DemotionNoticeV1, now: number): void;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* Decode a `promote`-protocol frame as a {@link PromotionNoticeV1} or {@link DemotionNoticeV1} (try one,
|
|
1854
|
+
* then the other), or `undefined` if it is neither. The two shapes are disjoint — a promotion carries
|
|
1855
|
+
* `fromTier`/`toTier`, a demotion carries `parentCohortCoord` — so the structural validators cleanly
|
|
1856
|
+
* discriminate.
|
|
1857
|
+
*/
|
|
1858
|
+
export function decodeInboundNotice(frame: Uint8Array, maxBytes?: number): InboundNotice | undefined {
|
|
1859
|
+
const decoded = decodeCohortMessage(frame, maxBytes);
|
|
1860
|
+
const promotion = tryValidate(() => validatePromotionNoticeV1(decoded));
|
|
1861
|
+
if (promotion !== undefined) {
|
|
1862
|
+
return { kind: "promotion", notice: promotion };
|
|
1863
|
+
}
|
|
1864
|
+
const demotion = tryValidate(() => validateDemotionNoticeV1(decoded));
|
|
1865
|
+
if (demotion !== undefined) {
|
|
1866
|
+
return { kind: "demotion", notice: demotion };
|
|
1867
|
+
}
|
|
1868
|
+
return undefined;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Per-coord minimum interval (ms) between membership refetches on the inbound `promote` path. Caps the
|
|
1873
|
+
* amplification a flood of forged notices can drive: a stream of verify-misses triggers at most one
|
|
1874
|
+
* `source.fetch()` per coord per this window, while a cold cache / membership rotation still re-fetches
|
|
1875
|
+
* once it elapses (eventual refetch preserved). 60 s mirrors the anti-DoS rate window.
|
|
1876
|
+
*/
|
|
1877
|
+
export const PROMOTE_REFETCH_MIN_INTERVAL_MS = 60_000;
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* Verify an inbound notice's threshold signature against the cohort `MembershipCertV1` for
|
|
1881
|
+
* `target.servedCoord` and, on success, apply it to the target's promotion lifecycle. Returns:
|
|
1882
|
+
*
|
|
1883
|
+
* - `"dropped"` — no local engine serves the notice's `(topic, tier)` (e.g. a demotion arriving at a
|
|
1884
|
+
* parent that does not track the child); nothing to apply to.
|
|
1885
|
+
* - `"untrusted"` — the `signers` are not a `≥ minSigs` subset of the cohort cert, or the multisig does
|
|
1886
|
+
* not verify (a forged single-signer / short-quorum notice); local state is left unchanged.
|
|
1887
|
+
* - `"applied"` — verified and applied.
|
|
1888
|
+
*
|
|
1889
|
+
* The payload is rebuilt with the canonical `sig/payloads` image the signer used — never re-canonicalized
|
|
1890
|
+
* independently. The verifier owns the cert lookup; this function never re-verifies inside the apply step
|
|
1891
|
+
* (db-core trusts this gate).
|
|
1892
|
+
*
|
|
1893
|
+
* **Bounded refetch (anti-amplification).** Both verify calls pass a {@link PROMOTE_REFETCH_MIN_INTERVAL_MS}
|
|
1894
|
+
* refetch bound, so a stream of forged notices drives at most **one** membership `source.fetch()` per coord
|
|
1895
|
+
* per interval rather than one dial per message. Eventual refetch is preserved (full suppression was
|
|
1896
|
+
* rejected: a legitimate sibling-adopt / demotion-to-parent notice whose cohort cert is not yet locally
|
|
1897
|
+
* cached must still be able to fetch it once — see `live-tier.spec.ts` test 4). A node that has cached its
|
|
1898
|
+
* own cohort cert via `onCertPublished` verifies a sibling-adopt notice from cache with zero fetches; a
|
|
1899
|
+
* cold-cache receiver pays one bounded fetch. Per `cohort-topic-promote-handler-verify-amplification`.
|
|
1900
|
+
*/
|
|
1901
|
+
export async function verifyAndApplyNotice(
|
|
1902
|
+
inbound: InboundNotice,
|
|
1903
|
+
target: NoticeApplyTarget | undefined,
|
|
1904
|
+
verifier: MembershipVerifier,
|
|
1905
|
+
now: number,
|
|
1906
|
+
): Promise<NoticeOutcome> {
|
|
1907
|
+
if (target === undefined) {
|
|
1908
|
+
return "dropped";
|
|
1909
|
+
}
|
|
1910
|
+
let signers: Uint8Array[];
|
|
1911
|
+
let sig: Uint8Array;
|
|
1912
|
+
try {
|
|
1913
|
+
signers = inbound.notice.signers.map(b64urlToBytes);
|
|
1914
|
+
sig = b64urlToBytes(inbound.notice.thresholdSig);
|
|
1915
|
+
} catch {
|
|
1916
|
+
return "untrusted"; // a signer / sig that is not valid base64url cannot verify
|
|
1917
|
+
}
|
|
1918
|
+
// Narrow on `inbound` (not a destructured `notice`) so the tier field and apply hook are typed per kind.
|
|
1919
|
+
if (inbound.kind === "promotion") {
|
|
1920
|
+
const payload = promotionNoticeSigningPayload(inbound.notice);
|
|
1921
|
+
const result = await verifier.verifyMessage(signers, target.servedCoord, inbound.notice.fromTier, payload, sig, { minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now });
|
|
1922
|
+
if (result !== "verified") {
|
|
1923
|
+
return "untrusted";
|
|
1924
|
+
}
|
|
1925
|
+
target.applyPromotionNotice(inbound.notice, now);
|
|
1926
|
+
return "applied";
|
|
1927
|
+
}
|
|
1928
|
+
const payload = demotionNoticeSigningPayload(inbound.notice);
|
|
1929
|
+
const result = await verifier.verifyMessage(signers, target.servedCoord, inbound.notice.tier, payload, sig, { minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now });
|
|
1930
|
+
if (result !== "verified") {
|
|
1931
|
+
return "untrusted";
|
|
1932
|
+
}
|
|
1933
|
+
target.applyDemotionNotice(inbound.notice, now);
|
|
1934
|
+
return "applied";
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Full inbound `promote`-frame pipeline with the anti-abuse gate, exported so it is unit-testable without a
|
|
1939
|
+
* live node (`cohort-topic-promote-handler-verify-amplification`). Runs the cheapest checks first — each
|
|
1940
|
+
* step strictly cheaper than the next — so a flood of forged frames is shed before any signature / network
|
|
1941
|
+
* work:
|
|
1942
|
+
*
|
|
1943
|
+
* ```
|
|
1944
|
+
* decode → per-(peer,topic) rate limit → findServing → effectiveAt high-water → verify+apply
|
|
1945
|
+
* ```
|
|
1946
|
+
*
|
|
1947
|
+
* - **Rate limit** (`gate.rateLimiter`) keys on `(from, topicId)`; an over-rate peer is dropped before the
|
|
1948
|
+
* `findServing` map scan and the verify, so a peer cannot amplify junk into verify/network work.
|
|
1949
|
+
* - **High-water** (`gate.highWater`, per `(topicId, tier)`) drops a notice whose `effectiveAt` is at or
|
|
1950
|
+
* below the last *applied* one — a replay / out-of-order frame — before `verifyMessage`. It is advanced
|
|
1951
|
+
* **only** on an `"applied"` outcome, so a forged frame (which never verifies) cannot poison it.
|
|
1952
|
+
* - The receiver-side `cohortEpoch` is intentionally **not** gated on: the epoch rotates on every
|
|
1953
|
+
* membership change, so a legitimately in-flight notice can briefly carry the prior epoch right after a
|
|
1954
|
+
* rotation — making an epoch check a brittle, false-positive-prone filter. The rate limiter + high-water
|
|
1955
|
+
* are the load-bearing defenses; the bounded refetch (in {@link verifyAndApplyNotice}) caps the network
|
|
1956
|
+
* amplification on the verify itself.
|
|
1957
|
+
*
|
|
1958
|
+
* `from` is the dialing peer's substrate bytes ({@link peerIdToBytes}); the node handler converts the
|
|
1959
|
+
* libp2p `PeerId` before calling. One-way contract: the caller sends no ack regardless of outcome.
|
|
1960
|
+
*/
|
|
1961
|
+
export async function handleInboundNotice(
|
|
1962
|
+
frame: Uint8Array,
|
|
1963
|
+
from: Uint8Array,
|
|
1964
|
+
registry: CoordRegistry,
|
|
1965
|
+
verifier: MembershipVerifier,
|
|
1966
|
+
gate: PromoteGate,
|
|
1967
|
+
now: number,
|
|
1968
|
+
maxBytes?: number,
|
|
1969
|
+
): Promise<InboundNoticeResult> {
|
|
1970
|
+
const inbound = decodeInboundNotice(frame, maxBytes);
|
|
1971
|
+
if (inbound === undefined) {
|
|
1972
|
+
log("promote: dropped an undecodable notice frame");
|
|
1973
|
+
return "undecodable";
|
|
1974
|
+
}
|
|
1975
|
+
const tier = inbound.kind === "promotion" ? inbound.notice.fromTier : inbound.notice.tier;
|
|
1976
|
+
const topicId = b64urlToBytes(inbound.notice.topicId);
|
|
1977
|
+
|
|
1978
|
+
// Per-(peer, topic) rate limit — before the findServing scan and the verify.
|
|
1979
|
+
if (gate.rateLimiter.check(from, topicId, now).ok === false) {
|
|
1980
|
+
log("promote: rate-limited %s notice for topic %s tier %d", inbound.kind, inbound.notice.topicId, tier);
|
|
1981
|
+
return "rate-limited";
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
const target = registry.findServing(topicId, tier);
|
|
1985
|
+
if (target === undefined) {
|
|
1986
|
+
log("promote: dropped %s notice for topic %s tier %d (no serving engine)", inbound.kind, inbound.notice.topicId, tier);
|
|
1987
|
+
return "dropped";
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Freshness / replay gate: drop an at-or-below-high-water notice before the expensive verify.
|
|
1991
|
+
const waterKey = `${inbound.notice.topicId}|${tier}`;
|
|
1992
|
+
const water = gate.highWater.get(waterKey);
|
|
1993
|
+
if (water !== undefined && inbound.notice.effectiveAt <= water) {
|
|
1994
|
+
log("promote: stale %s notice for topic %s tier %d (effectiveAt %d <= high-water %d)", inbound.kind, inbound.notice.topicId, tier, inbound.notice.effectiveAt, water);
|
|
1995
|
+
return "stale";
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
const outcome = await verifyAndApplyNotice(inbound, target, verifier, now);
|
|
1999
|
+
if (outcome === "applied") {
|
|
2000
|
+
// Advance the high-water only on a *verified-and-applied* notice, so a forged frame cannot poison it.
|
|
2001
|
+
gate.highWater.set(waterKey, inbound.notice.effectiveAt);
|
|
2002
|
+
} else {
|
|
2003
|
+
log("promote: %s %s notice for topic %s tier %d", outcome, inbound.kind, inbound.notice.topicId, tier);
|
|
2004
|
+
}
|
|
2005
|
+
return outcome;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* The cohort coords a notice is broadcast to over the `promote` protocol: always the cohort around
|
|
2010
|
+
* `servedCoord` (siblings adopt the state); for a demotion, additionally the parent cohort coord (so the
|
|
2011
|
+
* parent's `childCohortCount` bookkeeping converges). Pure — exported for testing the fan-out targets.
|
|
2012
|
+
*/
|
|
2013
|
+
export function noticeBroadcastCoords(notice: PromotionNoticeV1 | DemotionNoticeV1, servedCoord: RingCoord): RingCoord[] {
|
|
2014
|
+
if ("parentCohortCoord" in notice) {
|
|
2015
|
+
return [servedCoord, b64urlToBytes(notice.parentCohortCoord)];
|
|
2016
|
+
}
|
|
2017
|
+
return [servedCoord];
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// --- helpers ---
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Cross-check the cohort FRET routed the activity to against the locally recomputed assembly around
|
|
2024
|
+
* `servedCoord`. A mismatch (a slightly stale routing table) is logged; the recomputed assembly is
|
|
2025
|
+
* trusted, so renewal / gossip / signing — which run outside the activity callback — stay consistent.
|
|
2026
|
+
*/
|
|
2027
|
+
function crossCheckCohort(fret: FretService, wantK: number, servedCoord: RingCoord, fretCohort: readonly string[]): void {
|
|
2028
|
+
const assembled = fret.assembleCohort(servedCoord, wantK);
|
|
2029
|
+
if (!sameMemberSet(fretCohort, assembled)) {
|
|
2030
|
+
log(
|
|
2031
|
+
"cohort cross-check mismatch at coord %s: FRET-routed=%o assembled=%o; trusting the recomputed assembly",
|
|
2032
|
+
bytesToB64url(servedCoord),
|
|
2033
|
+
fretCohort,
|
|
2034
|
+
assembled,
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/** Positional equality over two string lists (the first-`k − x` rotation-change check). */
|
|
2040
|
+
function sameStringOrder(a: readonly string[], b: readonly string[]): boolean {
|
|
2041
|
+
if (a.length !== b.length) {
|
|
2042
|
+
return false;
|
|
2043
|
+
}
|
|
2044
|
+
for (let i = 0; i < a.length; i++) {
|
|
2045
|
+
if (a[i] !== b[i]) {
|
|
2046
|
+
return false;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
return true;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/** Set equality over two peer-id-string lists (order-independent). */
|
|
2053
|
+
function sameMemberSet(a: readonly string[], b: readonly string[]): boolean {
|
|
2054
|
+
if (a.length !== b.length) {
|
|
2055
|
+
return false;
|
|
2056
|
+
}
|
|
2057
|
+
const seen = new Set(a);
|
|
2058
|
+
return b.every((m) => seen.has(m));
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/** Count topics this member is currently `primary` for at `tier` (the willingness budget input). */
|
|
2062
|
+
function countPrimaryTopics(store: ReturnType<typeof createRegistrationStore>, self: Uint8Array, tier: Tier): number {
|
|
2063
|
+
const seen = new Set<string>();
|
|
2064
|
+
for (const rec of store.listAll()) {
|
|
2065
|
+
if (rec.tier === tier && bytesToB64url(rec.primary) === bytesToB64url(self)) {
|
|
2066
|
+
seen.add(bytesToB64url(rec.topicId));
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return seen.size;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** Resolve a topic's op tier from any held record (0 if none yet). */
|
|
2073
|
+
function tierOfTopic(store: ReturnType<typeof createRegistrationStore>, topicId: Uint8Array): Tier {
|
|
2074
|
+
const recs = store.listByTopic(topicId);
|
|
2075
|
+
return (recs.length > 0 ? recs[0]!.tier : 0) as Tier;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
async function registerProtocolHandlers(
|
|
2079
|
+
node: Libp2p,
|
|
2080
|
+
protocols: CohortTopicProtocols,
|
|
2081
|
+
registry: CoordRegistry,
|
|
2082
|
+
dispatchRegister: (reg: RegisterV1, fretCohort: readonly string[] | undefined, now: number) => Promise<RegisterReplyV1>,
|
|
2083
|
+
signEndorse: (request: SignRequestV1, fromPeerStr: string) => Promise<SignReplyV1>,
|
|
2084
|
+
verifier: MembershipVerifier,
|
|
2085
|
+
promoteGate: PromoteGate,
|
|
2086
|
+
gossipTransport: FretCohortGossipTransport,
|
|
2087
|
+
publishSink: FretMembershipPublishSink,
|
|
2088
|
+
membershipSource: FretMembershipSource,
|
|
2089
|
+
selfCoord: RingCoord,
|
|
2090
|
+
maxBytes: number,
|
|
2091
|
+
): Promise<void> {
|
|
2092
|
+
await Promise.all([
|
|
2093
|
+
// register: a direct dial carries either a RegisterV1 (re-attach walk fallback) or a RenewV1 (ping).
|
|
2094
|
+
node.handle(protocols.register, makeFrameHandler(async (frame): Promise<Uint8Array | undefined> => {
|
|
2095
|
+
const decoded = decodeCohortMessage(frame, maxBytes);
|
|
2096
|
+
const renew = tryValidate(() => validateRenewV1(decoded));
|
|
2097
|
+
if (renew !== undefined) {
|
|
2098
|
+
return encodeCohortMessage(resolveRenew(registry, renew, Date.now()), maxBytes);
|
|
2099
|
+
}
|
|
2100
|
+
const reg = validateRegisterV1(decoded);
|
|
2101
|
+
// Direct dial (not FRET-routed): no cohort member list to cross-check against.
|
|
2102
|
+
const reply = await dispatchRegister(reg, undefined, Date.now());
|
|
2103
|
+
return encodeCohortMessage(reply, maxBytes);
|
|
2104
|
+
}, maxBytes)),
|
|
2105
|
+
|
|
2106
|
+
// cohort-gossip: feed inbound gossip into the shared transport (one-way). It fans the frame to
|
|
2107
|
+
// every coord engine's bus; per-bus epoch matching governs which engine merges the record deltas.
|
|
2108
|
+
node.handle(protocols.gossip, makeFrameHandler(async (frame, from): Promise<Uint8Array | undefined> => {
|
|
2109
|
+
gossipTransport.deliver(from.toString(), frame);
|
|
2110
|
+
return undefined;
|
|
2111
|
+
}, maxBytes)),
|
|
2112
|
+
|
|
2113
|
+
// promote: threshold-signed promotion/demotion notices (one-way, gossip-style fan-out). The dialing
|
|
2114
|
+
// peer arrives as `from`, so the handler can gate per-(peer, topic) before any expensive work. The
|
|
2115
|
+
// full pipeline (rate limit → findServing → effectiveAt high-water → verify+apply, bounded
|
|
2116
|
+
// refetch) lives in the exported `handleInboundNotice`; it logs and never throws on the stream.
|
|
2117
|
+
node.handle(protocols.promote, makeFrameHandler(async (frame, from): Promise<Uint8Array | undefined> => {
|
|
2118
|
+
await handleInboundNotice(frame, peerIdToBytes(from), registry, verifier, promoteGate, Date.now(), maxBytes);
|
|
2119
|
+
return undefined; // one-way: match the gossip-style fan-out (no ack frame)
|
|
2120
|
+
}, maxBytes)),
|
|
2121
|
+
|
|
2122
|
+
// membership: serve this node's latest published cert; cache any cert the requester returns.
|
|
2123
|
+
node.handle(protocols.membership, makeFrameHandler(async (frame): Promise<Uint8Array | undefined> => {
|
|
2124
|
+
void frame; // request frame is the raw coord; this node serves its own cohort cert
|
|
2125
|
+
const latest = publishSink.latest();
|
|
2126
|
+
if (latest !== undefined) {
|
|
2127
|
+
membershipSource.cache(selfCoord, latest);
|
|
2128
|
+
}
|
|
2129
|
+
return latest ?? new Uint8Array(0);
|
|
2130
|
+
}, maxBytes)),
|
|
2131
|
+
|
|
2132
|
+
// sign: per-member endorsement for threshold-signature assembly. Validate the request, run the
|
|
2133
|
+
// endorsement policy, and reply with this node's peer-key signature over the request payload (or a
|
|
2134
|
+
// refusal). One Ed25519 sign and nothing more — the cohort + epoch gate bounds who we sign for.
|
|
2135
|
+
node.handle(protocols.sign, makeFrameHandler(async (frame, from): Promise<Uint8Array | undefined> => {
|
|
2136
|
+
const request = validateSignRequestV1(decodeCohortMessage(frame, maxBytes));
|
|
2137
|
+
const reply = await signEndorse(request, from.toString());
|
|
2138
|
+
return encodeCohortMessage(reply, maxBytes);
|
|
2139
|
+
}, maxBytes)),
|
|
2140
|
+
]);
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
/** Wrap a frame handler in the read-one / reply-one libp2p stream lifecycle. */
|
|
2144
|
+
function makeFrameHandler(
|
|
2145
|
+
handle: (frame: Uint8Array, from: PeerId) => Promise<Uint8Array | undefined>,
|
|
2146
|
+
maxBytes: number,
|
|
2147
|
+
): (stream: Stream, connection: Connection) => void {
|
|
2148
|
+
return (stream: Stream, connection: Connection): void => {
|
|
2149
|
+
void (async (): Promise<void> => {
|
|
2150
|
+
try {
|
|
2151
|
+
const frame = await readAllBounded(stream, maxBytes);
|
|
2152
|
+
const reply = await handle(frame, connection.remotePeer);
|
|
2153
|
+
if (reply !== undefined) {
|
|
2154
|
+
stream.send(reply);
|
|
2155
|
+
}
|
|
2156
|
+
await stream.close();
|
|
2157
|
+
} catch {
|
|
2158
|
+
try {
|
|
2159
|
+
stream.abort(new Error("cohort-topic handler error"));
|
|
2160
|
+
} catch {
|
|
2161
|
+
/* already aborted */
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
})();
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/** Run `fn`, returning `undefined` if it throws (used to try one validator then fall through). */
|
|
2169
|
+
function tryValidate<T>(fn: () => T): T | undefined {
|
|
2170
|
+
try {
|
|
2171
|
+
return fn();
|
|
2172
|
+
} catch {
|
|
2173
|
+
return undefined;
|
|
2174
|
+
}
|
|
2175
|
+
}
|