@optimystic/db-p2p 0.24.2 → 0.25.0
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 → README.md} +688 -668
- package/dist/src/cluster/block-transfer-service.d.ts +133 -10
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +155 -9
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/block-transfer.d.ts +67 -7
- package/dist/src/cluster/block-transfer.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer.js +127 -24
- package/dist/src/cluster/block-transfer.js.map +1 -1
- package/dist/src/cluster/certified-claims.d.ts +170 -0
- package/dist/src/cluster/certified-claims.d.ts.map +1 -0
- package/dist/src/cluster/certified-claims.js +200 -0
- package/dist/src/cluster/certified-claims.js.map +1 -0
- package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
- package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
- package/dist/src/cluster/client-signature-verifier.js +33 -0
- package/dist/src/cluster/client-signature-verifier.js.map +1 -0
- package/dist/src/cluster/cluster-policy.d.ts +9 -3
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
- package/dist/src/cluster/cluster-policy.js +111 -27
- package/dist/src/cluster/cluster-policy.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +140 -11
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +485 -29
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/commit-proof.d.ts +134 -0
- package/dist/src/cluster/commit-proof.d.ts.map +1 -0
- package/dist/src/cluster/commit-proof.js +263 -0
- package/dist/src/cluster/commit-proof.js.map +1 -0
- package/dist/src/cluster/quorum-restore.d.ts +95 -8
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +113 -14
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
- package/dist/src/cluster/rebalance-monitor.js +207 -5
- package/dist/src/cluster/rebalance-monitor.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts +27 -7
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +179 -38
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/cluster/service.d.ts +13 -4
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +17 -18
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts +8 -2
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +7 -7
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +8 -7
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +7 -6
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +14 -41
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/dispute/cascade.d.ts +7 -8
- package/dist/src/dispute/cascade.d.ts.map +1 -1
- package/dist/src/dispute/cascade.js +24 -7
- package/dist/src/dispute/cascade.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts +5 -0
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +13 -3
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/dispute/invalidation.d.ts +33 -15
- package/dist/src/dispute/invalidation.d.ts.map +1 -1
- package/dist/src/dispute/invalidation.js +127 -34
- package/dist/src/dispute/invalidation.js.map +1 -1
- package/dist/src/dispute/service.d.ts.map +1 -1
- package/dist/src/dispute/service.js +2 -1
- package/dist/src/dispute/service.js.map +1 -1
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +14 -10
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +28 -58
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +38 -7
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +148 -63
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/network/open-protocol-stream.d.ts +56 -0
- package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
- package/dist/src/network/open-protocol-stream.js +60 -0
- package/dist/src/network/open-protocol-stream.js.map +1 -0
- package/dist/src/network/register-protocol-handler.d.ts +66 -0
- package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
- package/dist/src/network/register-protocol-handler.js +51 -0
- package/dist/src/network/register-protocol-handler.js.map +1 -0
- package/dist/src/peer-address-book.d.ts +60 -2
- package/dist/src/peer-address-book.d.ts.map +1 -1
- package/dist/src/peer-address-book.js +89 -1
- package/dist/src/peer-address-book.js.map +1 -1
- package/dist/src/pend-validation.d.ts +65 -0
- package/dist/src/pend-validation.d.ts.map +1 -0
- package/dist/src/pend-validation.js +69 -0
- package/dist/src/pend-validation.js.map +1 -0
- package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
- package/dist/src/reactivity/notify-transport.js +2 -1
- package/dist/src/reactivity/notify-transport.js.map +1 -1
- package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
- package/dist/src/reactivity/push-state-gossip.js +2 -1
- package/dist/src/reactivity/push-state-gossip.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +32 -6
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +44 -10
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +181 -25
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +650 -62
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/served-repo-proxy.d.ts +30 -0
- package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
- package/dist/src/repo/served-repo-proxy.js +50 -0
- package/dist/src/repo/served-repo-proxy.js.map +1 -0
- package/dist/src/repo/service.d.ts +16 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +18 -16
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/reputation/types.d.ts +3 -1
- package/dist/src/reputation/types.d.ts.map +1 -1
- package/dist/src/reputation/types.js +3 -1
- package/dist/src/reputation/types.js.map +1 -1
- package/dist/src/rn.d.ts +24 -2
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +33 -4
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/block-archive.d.ts +153 -0
- package/dist/src/storage/block-archive.d.ts.map +1 -0
- package/dist/src/storage/block-archive.js +194 -0
- package/dist/src/storage/block-archive.js.map +1 -0
- package/dist/src/storage/block-latch.d.ts +92 -0
- package/dist/src/storage/block-latch.d.ts.map +1 -0
- package/dist/src/storage/block-latch.js +132 -0
- package/dist/src/storage/block-latch.js.map +1 -0
- package/dist/src/storage/block-storage.d.ts +169 -12
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +415 -152
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +5 -1
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/cached-raw-storage.js +16 -1
- package/dist/src/storage/cached-raw-storage.js.map +1 -1
- package/dist/src/storage/cached-store-driver.d.ts +14 -4
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
- package/dist/src/storage/cached-store-driver.js +39 -5
- package/dist/src/storage/cached-store-driver.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +70 -19
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js +21 -1
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +38 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +14 -8
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.js +29 -9
- package/dist/src/storage/kv-raw-storage.js.map +1 -1
- package/dist/src/storage/memory-store-driver.d.ts +4 -1
- package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
- package/dist/src/storage/memory-store-driver.js +22 -2
- package/dist/src/storage/memory-store-driver.js.map +1 -1
- package/dist/src/storage/raw-store-codec.js +2 -2
- package/dist/src/storage/raw-store-driver.d.ts +44 -3
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +60 -3
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.js +68 -4
- package/dist/src/storage/shared-cache-pool.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +204 -26
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +422 -119
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/store-identity.d.ts +21 -0
- package/dist/src/storage/store-identity.d.ts.map +1 -0
- package/dist/src/storage/store-identity.js +33 -0
- package/dist/src/storage/store-identity.js.map +1 -0
- package/dist/src/storage/struct.d.ts +15 -0
- package/dist/src/storage/struct.d.ts.map +1 -1
- package/dist/src/storage/with-read-cache.d.ts +92 -0
- package/dist/src/storage/with-read-cache.d.ts.map +1 -0
- package/dist/src/storage/with-read-cache.js +132 -0
- package/dist/src/storage/with-read-cache.js.map +1 -0
- package/dist/src/sync/service.d.ts +13 -3
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +7 -30
- package/dist/src/sync/service.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +75 -2
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +193 -81
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +97 -2
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +5 -6
- package/src/cluster/block-transfer-service.ts +528 -312
- package/src/cluster/block-transfer.ts +547 -424
- package/src/cluster/certified-claims.ts +319 -0
- package/src/cluster/client-signature-verifier.ts +34 -0
- package/src/cluster/cluster-policy.ts +294 -203
- package/src/cluster/cluster-repo.ts +2501 -2034
- package/src/cluster/commit-proof.ts +344 -0
- package/src/cluster/quorum-restore.ts +348 -223
- package/src/cluster/rebalance-monitor.ts +539 -254
- package/src/cluster/reconcile-block.ts +404 -203
- package/src/cluster/service.ts +23 -20
- package/src/cluster/spread-on-churn.ts +352 -346
- package/src/cohort-topic/host.ts +8 -7
- package/src/cohort-topic/stream-util.ts +14 -43
- package/src/dispute/cascade.ts +31 -15
- package/src/dispute/dispute-service.ts +706 -696
- package/src/dispute/invalidation.ts +162 -52
- package/src/dispute/service.ts +126 -125
- package/src/index.ts +7 -0
- package/src/libp2p-key-network.ts +1204 -1235
- package/src/libp2p-node-base.ts +185 -70
- package/src/network/open-protocol-stream.ts +87 -0
- package/src/network/register-protocol-handler.ts +85 -0
- package/src/peer-address-book.ts +96 -2
- package/src/pend-validation.ts +89 -0
- package/src/reactivity/notify-transport.ts +2 -1
- package/src/reactivity/push-state-gossip.ts +2 -1
- package/src/repo/cluster-coordinator.ts +65 -11
- package/src/repo/coordinator-repo.ts +729 -71
- package/src/repo/served-repo-proxy.ts +55 -0
- package/src/repo/service.ts +25 -16
- package/src/reputation/types.ts +3 -1
- package/src/rn.ts +34 -4
- package/src/storage/block-archive.ts +242 -0
- package/src/storage/block-latch.ts +144 -0
- package/src/storage/block-storage.ts +787 -498
- package/src/storage/cached-raw-storage.ts +21 -1
- package/src/storage/cached-store-driver.ts +898 -859
- package/src/storage/i-block-storage.ts +172 -116
- package/src/storage/i-raw-storage.ts +40 -0
- package/src/storage/kv-raw-storage.ts +34 -9
- package/src/storage/memory-store-driver.ts +26 -2
- package/src/storage/raw-store-codec.ts +42 -42
- package/src/storage/raw-store-driver.ts +45 -3
- package/src/storage/shared-cache-pool.ts +516 -452
- package/src/storage/storage-repo.ts +1421 -1014
- package/src/storage/store-identity.ts +41 -0
- package/src/storage/struct.ts +52 -34
- package/src/storage/with-read-cache.ts +182 -0
- package/src/sync/service.ts +178 -198
- package/src/testing/mesh-harness.ts +524 -344
- package/src/testing/raw-storage-conformance.ts +114 -2
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { ActionId, BlockId, ClusterRecord, IBlock, RepoMessage, Signature, CommitRequest } from "@optimystic/db-core";
|
|
2
|
+
import {
|
|
3
|
+
canonicalBlockHash, clusterVoteVerificationPayload, computeClusterCommitHash,
|
|
4
|
+
computeClusterMessageHash, computeClusterPromiseHash, membershipDigestFromIds
|
|
5
|
+
} from "@optimystic/db-core";
|
|
6
|
+
import { peerIdFromString } from "@libp2p/peer-id";
|
|
7
|
+
import { publicKeyFromRaw } from "@libp2p/crypto/keys";
|
|
8
|
+
import { fromString as uint8ArrayFromString } from "uint8arrays/from-string";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A durable, self-contained proof that a cluster cohort agreed on a commit — everything an offline
|
|
12
|
+
* verifier needs to re-derive the consensus hashes and check every vote signature, with NO access to
|
|
13
|
+
* the live cluster. Unlike a `CommitCert` (whose `signedPayload` is an opaque hash preimage, retained
|
|
14
|
+
* only ~60s in memory for reactivity), this artifact carries the commit `RepoMessage` itself, so a
|
|
15
|
+
* receiver can check the claim "block B at revision R holds these bytes" against it.
|
|
16
|
+
*
|
|
17
|
+
* **No public keys are carried.** Every signer's Ed25519 key is recovered from its peer id — the id
|
|
18
|
+
* IS the multihash of the key (the mechanism `peerIdBindsPublicKey` relies on), so carrying keys
|
|
19
|
+
* would add bytes and a second thing to disagree with the id.
|
|
20
|
+
*
|
|
21
|
+
* **Measured size**: 4578 bytes serialized for a 10-peer cohort signing a two-block fully-signed
|
|
22
|
+
* commit (`JSON.stringify(proof).length`, pinned by the "size" test in `test/commit-proof.spec.ts`).
|
|
23
|
+
* It rides inside sync responses bounded by `MAX_CONTROL_MESSAGE_BYTES` (1 MiB), which already carry
|
|
24
|
+
* whole blocks, so proof size is a rounding error there.
|
|
25
|
+
*/
|
|
26
|
+
export type BlockCommitProof = {
|
|
27
|
+
v: 1;
|
|
28
|
+
messageHash: string;
|
|
29
|
+
/** The commit RepoMessage exactly as hashed - carries the commit op incl. blockDigests. */
|
|
30
|
+
message: RepoMessage;
|
|
31
|
+
/** Promise-round votes, verbatim: the commitHash preimage includes canonicalJson(promises),
|
|
32
|
+
* and the approve promises are the votes that actually carry "I checked this". */
|
|
33
|
+
promises: Record<string, Signature>;
|
|
34
|
+
/** Commit-round votes. */
|
|
35
|
+
commits: Record<string, Signature>;
|
|
36
|
+
/** Always 2. A v1 / unversioned record binds no peer set and is never certifiable. */
|
|
37
|
+
membershipVersion: 2;
|
|
38
|
+
membershipDigest: string;
|
|
39
|
+
/** The record's full sorted peer-id list - the threshold denominator, bound by membershipDigest. */
|
|
40
|
+
peerIds: string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The claim a proof is checked against: this block, at this revision, under this committing action. */
|
|
44
|
+
export type ProofClaim = { blockId: BlockId; rev: number; actionId: ActionId };
|
|
45
|
+
|
|
46
|
+
export type ProofThresholds = {
|
|
47
|
+
/** Gates the promise-round approvals: `approves >= ceil(superMajorityThreshold * peerIds.length)`. */
|
|
48
|
+
superMajorityThreshold: number;
|
|
49
|
+
/**
|
|
50
|
+
* Gates the commit-round approvals: `approves > peerIds.length * simpleMajorityThreshold`. Pass
|
|
51
|
+
* **0.5** to mirror what members actually enforce — `ClusterMember.hasMajority` hardcodes
|
|
52
|
+
* `count > total / 2` (cluster-repo.ts), NOT the config's 0.51 default.
|
|
53
|
+
*/
|
|
54
|
+
simpleMajorityThreshold: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type ProofFailure =
|
|
58
|
+
| 'legacy-record' | 'membership-mismatch' | 'message-hash-mismatch'
|
|
59
|
+
| 'unknown-signer' | 'duplicate-signer' | 'non-ed25519-signer' | 'malformed-signature'
|
|
60
|
+
| 'promise-threshold' | 'commit-threshold'
|
|
61
|
+
| 'claim-not-in-message' | 'no-digest-declared' | 'digest-mismatch'
|
|
62
|
+
/** Structurally invalid input (wrong types, missing fields, throwing shapes). Not in the original
|
|
63
|
+
* spec enum — added deliberately so "pure and total on hostile input" has an honest catch-all
|
|
64
|
+
* instead of mislabeling garbage as one of the semantic failures. */
|
|
65
|
+
| 'malformed-proof';
|
|
66
|
+
|
|
67
|
+
export type ProofVerdict =
|
|
68
|
+
| { ok: true; declaredDigest?: string }
|
|
69
|
+
| { ok: false; reason: ProofFailure };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Project a consensus-committed {@link ClusterRecord} into a {@link BlockCommitProof}. A cheap
|
|
73
|
+
* projection — no hashing, no signature work; the record's maps are carried by reference (the
|
|
74
|
+
* storage layer JSON-snapshots on save). Returns `undefined` for a v1 / unversioned record: its
|
|
75
|
+
* hashes bind no peer set, so its signer list is unbound and it can never be certified (the caller
|
|
76
|
+
* logs the skip).
|
|
77
|
+
*/
|
|
78
|
+
export function buildBlockCommitProof(record: ClusterRecord): BlockCommitProof | undefined {
|
|
79
|
+
if (record.membershipVersion !== 2 || typeof record.membershipDigest !== 'string') {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
v: 1,
|
|
84
|
+
messageHash: record.messageHash,
|
|
85
|
+
message: record.message,
|
|
86
|
+
promises: record.promises,
|
|
87
|
+
commits: record.commits,
|
|
88
|
+
membershipVersion: 2,
|
|
89
|
+
membershipDigest: record.membershipDigest,
|
|
90
|
+
peerIds: Object.keys(record.peers).sort()
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Verify that `proof` certifies the CLAIM — `claim.blockId` at `claim.rev` under `claim.actionId` —
|
|
96
|
+
* without needing the block bytes. Pure and total on hostile input: never throws; every failure is a
|
|
97
|
+
* distinguishable {@link ProofFailure}.
|
|
98
|
+
*
|
|
99
|
+
* Mirrors the outcome discipline of `ClusterMember.verifySignature`: a malformed or unbound signer is
|
|
100
|
+
* a verification failure that must NEVER be turned into a reputation penalty, because the identity
|
|
101
|
+
* behind it was not proven. Callers deciding attributability must key off the reason values, and
|
|
102
|
+
* treat `unknown-signer` / `non-ed25519-signer` / `malformed-signature` / `malformed-proof` as
|
|
103
|
+
* non-attributable.
|
|
104
|
+
*
|
|
105
|
+
* **Why both thresholds.** The commit-round vote is cast blind — a member signs the commit whenever
|
|
106
|
+
* the approve-promises reach super-majority, regardless of its own promise vote. The promise-round
|
|
107
|
+
* approvals are the votes that carry "I validated this message, including its content digest".
|
|
108
|
+
* Requiring only the commit approvals would count signatures that attest to nothing; requiring only
|
|
109
|
+
* the promises would accept a record the cohort never actually committed. Require both.
|
|
110
|
+
*
|
|
111
|
+
* The claim step (a `{ commit }` op whose `blockIds`/`actionId`/`rev` all match) is what stops
|
|
112
|
+
* replay: a genuine proof for rev 5 presented for rev 9, or for a different block id, dies there.
|
|
113
|
+
*
|
|
114
|
+
* ## What a passing verdict does NOT prove — two caller obligations
|
|
115
|
+
*
|
|
116
|
+
* 1. **The signers are not bound to the block.** A verdict says "the cohort in `proof.peerIds`
|
|
117
|
+
* agreed", never "that is the cohort responsible for `claim.blockId`". Any attacker who controls
|
|
118
|
+
* N keys can stand up their own N-peer cohort, sign a commit for any block id at any revision,
|
|
119
|
+
* and produce a proof that verifies here. Nothing offline can close this: a block's cohort is
|
|
120
|
+
* chosen by live placement and rotates over history, so `peerIds` cannot be checked against a
|
|
121
|
+
* fixed expected set. A caller accepting proofs from untrusted peers MUST corroborate the cohort
|
|
122
|
+
* separately (overlap with the block's currently-derived cohort, or a membership anchor — see
|
|
123
|
+
* `feat-cluster-membership-threshold-cert-anchoring`). Both repair paths do this via
|
|
124
|
+
* `cluster/certified-claims.ts`, whose layer 2 logs the overlap and surfaces the unanchored
|
|
125
|
+
* residual; the anchor itself is still open.
|
|
126
|
+
* 2. **Cost is attacker-chosen.** This performs one Ed25519 verify per approve vote and hashes the
|
|
127
|
+
* whole message; nothing here caps `peerIds`, the vote maps, or the message. A caller reading a
|
|
128
|
+
* proof off the wire must bound its size and cohort count BEFORE calling.
|
|
129
|
+
*/
|
|
130
|
+
export async function verifyBlockCommitProofClaim(
|
|
131
|
+
proof: BlockCommitProof, claim: ProofClaim, thresholds: ProofThresholds
|
|
132
|
+
): Promise<ProofVerdict> {
|
|
133
|
+
try {
|
|
134
|
+
if (proof === null || typeof proof !== 'object') {
|
|
135
|
+
return { ok: false, reason: 'malformed-proof' };
|
|
136
|
+
}
|
|
137
|
+
// A version this verifier does not implement is never half-verified. `membershipVersion !== 2`
|
|
138
|
+
// means the signer list is unbound (v1 / unversioned history) — never certifiable.
|
|
139
|
+
if (proof.v !== 1 || proof.membershipVersion !== 2) {
|
|
140
|
+
return { ok: false, reason: 'legacy-record' };
|
|
141
|
+
}
|
|
142
|
+
if (typeof proof.messageHash !== 'string'
|
|
143
|
+
|| typeof proof.membershipDigest !== 'string'
|
|
144
|
+
|| proof.message === null || typeof proof.message !== 'object'
|
|
145
|
+
|| !Array.isArray(proof.message.operations)
|
|
146
|
+
|| !isVoteMap(proof.promises) || !isVoteMap(proof.commits)
|
|
147
|
+
|| !Array.isArray(proof.peerIds) || !proof.peerIds.every(id => typeof id === 'string')) {
|
|
148
|
+
return { ok: false, reason: 'malformed-proof' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A duplicated id in the denominator list would let one signer count twice against the
|
|
152
|
+
// threshold. (A JSON-parsed vote map cannot carry duplicate keys, so the array is the one
|
|
153
|
+
// reachable site for duplication.)
|
|
154
|
+
const peerSet = new Set(proof.peerIds);
|
|
155
|
+
if (peerSet.size !== proof.peerIds.length) {
|
|
156
|
+
return { ok: false, reason: 'duplicate-signer' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The peer list must be the one every signature covers: recompute the membership digest from
|
|
160
|
+
// it, then the message hash over (message + digest). Tampering with peerIds dies here;
|
|
161
|
+
// tampering with the message dies on the next check.
|
|
162
|
+
if (await membershipDigestFromIds(proof.peerIds) !== proof.membershipDigest) {
|
|
163
|
+
return { ok: false, reason: 'membership-mismatch' };
|
|
164
|
+
}
|
|
165
|
+
if (await computeClusterMessageHash(proof.message, proof.membershipDigest) !== proof.messageHash) {
|
|
166
|
+
return { ok: false, reason: 'message-hash-mismatch' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const promiseHash = await computeClusterPromiseHash(proof.messageHash, proof.message, proof.membershipDigest);
|
|
170
|
+
const commitHash = await computeClusterCommitHash(proof.messageHash, proof.message, proof.promises, proof.membershipDigest);
|
|
171
|
+
|
|
172
|
+
const promiseRound = await countApprovals(proof.promises, promiseHash, peerSet);
|
|
173
|
+
if ('reason' in promiseRound) {
|
|
174
|
+
return { ok: false, reason: promiseRound.reason };
|
|
175
|
+
}
|
|
176
|
+
const commitRound = await countApprovals(proof.commits, commitHash, peerSet);
|
|
177
|
+
if ('reason' in commitRound) {
|
|
178
|
+
return { ok: false, reason: commitRound.reason };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Threshold gates. When a threshold fails AND that round skipped a signer outside `peerIds`,
|
|
182
|
+
// report `unknown-signer` rather than the bare threshold reason — the skipped signature is the
|
|
183
|
+
// record of why the count came up short.
|
|
184
|
+
const superMajority = Math.ceil(thresholds.superMajorityThreshold * proof.peerIds.length);
|
|
185
|
+
if (promiseRound.approves < superMajority) {
|
|
186
|
+
return { ok: false, reason: promiseRound.sawUnknownSigner ? 'unknown-signer' : 'promise-threshold' };
|
|
187
|
+
}
|
|
188
|
+
if (!(commitRound.approves > proof.peerIds.length * thresholds.simpleMajorityThreshold)) {
|
|
189
|
+
return { ok: false, reason: commitRound.sawUnknownSigner ? 'unknown-signer' : 'commit-threshold' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const op = findClaimedCommitOp(proof.message, claim);
|
|
193
|
+
if (!op) {
|
|
194
|
+
return { ok: false, reason: 'claim-not-in-message' };
|
|
195
|
+
}
|
|
196
|
+
return { ok: true, declaredDigest: op.blockDigests?.[claim.blockId]?.digest };
|
|
197
|
+
} catch {
|
|
198
|
+
return { ok: false, reason: 'malformed-proof' };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Claim verification ({@link verifyBlockCommitProofClaim}) PLUS: the digest the commit op declared
|
|
204
|
+
* for `claim.blockId` must be present (`no-digest-declared`) and equal `canonicalBlockHash(block)`
|
|
205
|
+
* (`digest-mismatch`). Total like the claim half; a block shape that `canonicalBlockHash` cannot
|
|
206
|
+
* digest reads as `digest-mismatch` — the received bytes provably are not the declared content.
|
|
207
|
+
*/
|
|
208
|
+
export async function verifyBlockCommitProofContent(
|
|
209
|
+
proof: BlockCommitProof, claim: ProofClaim, block: IBlock, thresholds: ProofThresholds
|
|
210
|
+
): Promise<ProofVerdict> {
|
|
211
|
+
const verdict = await verifyBlockCommitProofClaim(proof, claim, thresholds);
|
|
212
|
+
if (!verdict.ok) {
|
|
213
|
+
return verdict;
|
|
214
|
+
}
|
|
215
|
+
if (typeof verdict.declaredDigest !== 'string') {
|
|
216
|
+
return { ok: false, reason: 'no-digest-declared' };
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
if (await canonicalBlockHash(block) !== verdict.declaredDigest) {
|
|
220
|
+
return { ok: false, reason: 'digest-mismatch' };
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
return { ok: false, reason: 'digest-mismatch' };
|
|
224
|
+
}
|
|
225
|
+
return verdict;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The digest `proof.message`'s commit operation declares for the claimed block, or `undefined`
|
|
230
|
+
* when the claim resolves no commit op or the op carries no digest for that block id. This is the
|
|
231
|
+
* SAME op resolution {@link verifyBlockCommitProofClaim}'s claim step uses, so the digest the
|
|
232
|
+
* storage layer's retention rule compares against (persist only when the local materialization
|
|
233
|
+
* matches) can never drift from the digest a later verifier extracts.
|
|
234
|
+
*/
|
|
235
|
+
export function proofDeclaredDigest(proof: BlockCommitProof, claim: ProofClaim): string | undefined {
|
|
236
|
+
return findClaimedCommitOp(proof.message, claim)?.blockDigests?.[claim.blockId]?.digest;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Does `proof`'s message actually carry a commit operation for this exact
|
|
241
|
+
* `(blockId, rev, actionId)`? The claim step of {@link verifyBlockCommitProofClaim} with NO
|
|
242
|
+
* cryptography — the same {@link findClaimedCommitOp} resolution, so the cheap structural check and
|
|
243
|
+
* the full verification can never disagree about which op a claim names.
|
|
244
|
+
*
|
|
245
|
+
* This is the pairing guard a SERVER uses: a proof is looked up by revision, so attaching one whose
|
|
246
|
+
* message names a different revision (or block, or action) would be publishing a mis-paired
|
|
247
|
+
* artifact. It says nothing about signatures or thresholds — a receiver must still verify. Total on
|
|
248
|
+
* hostile input like its verifying sibling: never throws, `false` on any malformed shape.
|
|
249
|
+
*/
|
|
250
|
+
export function proofClaimsCommit(proof: BlockCommitProof, claim: ProofClaim): boolean {
|
|
251
|
+
try {
|
|
252
|
+
return findClaimedCommitOp(proof.message, claim) !== undefined;
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A plain-object (non-array) map of votes — the shape `promises` / `commits` must have. */
|
|
259
|
+
function isVoteMap(value: unknown): value is Record<string, Signature> {
|
|
260
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Count distinct `approve` votes over `hash` from signers inside `peerSet`, verifying each signature
|
|
265
|
+
* against the Ed25519 key its peer id names. Per-entry rules (the spec's step 5/6):
|
|
266
|
+
*
|
|
267
|
+
* - a non-`approve` vote is ignored silently (rejects/conflicts are part of the signed record but
|
|
268
|
+
* never count toward approval);
|
|
269
|
+
* - a signer outside `peerSet` is skipped and noted (`sawUnknownSigner`) — the caller downgrades a
|
|
270
|
+
* failed threshold to `unknown-signer`;
|
|
271
|
+
* - a signer inside `peerSet` whose id is not a valid Ed25519 peer id → immediate
|
|
272
|
+
* `non-ed25519-signer` (its key cannot be recovered, so the proof as a whole is unverifiable);
|
|
273
|
+
* - a signature that is not a string, fails base64url decode, or fails cryptographic verify →
|
|
274
|
+
* immediate `malformed-signature`;
|
|
275
|
+
* - each signer id is counted at most once.
|
|
276
|
+
*/
|
|
277
|
+
async function countApprovals(
|
|
278
|
+
votes: Record<string, Signature>,
|
|
279
|
+
hash: string,
|
|
280
|
+
peerSet: ReadonlySet<string>
|
|
281
|
+
): Promise<{ approves: number; sawUnknownSigner: boolean } | { reason: ProofFailure }> {
|
|
282
|
+
let approves = 0;
|
|
283
|
+
let sawUnknownSigner = false;
|
|
284
|
+
const counted = new Set<string>();
|
|
285
|
+
for (const [signerId, vote] of Object.entries(votes)) {
|
|
286
|
+
if (vote === null || typeof vote !== 'object' || vote.type !== 'approve') {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!peerSet.has(signerId)) {
|
|
290
|
+
sawUnknownSigner = true;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (counted.has(signerId)) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
let rawKey: Uint8Array;
|
|
297
|
+
try {
|
|
298
|
+
const peerId = peerIdFromString(signerId);
|
|
299
|
+
if (peerId.type !== 'Ed25519' || peerId.publicKey === undefined) {
|
|
300
|
+
return { reason: 'non-ed25519-signer' };
|
|
301
|
+
}
|
|
302
|
+
rawKey = peerId.publicKey.raw;
|
|
303
|
+
} catch {
|
|
304
|
+
// An id that does not even parse as a peer id certainly names no Ed25519 key.
|
|
305
|
+
return { reason: 'non-ed25519-signer' };
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
if (typeof vote.signature !== 'string') {
|
|
309
|
+
return { reason: 'malformed-signature' };
|
|
310
|
+
}
|
|
311
|
+
const sigBytes = uint8ArrayFromString(vote.signature, 'base64url');
|
|
312
|
+
const payload = clusterVoteVerificationPayload(hash, vote);
|
|
313
|
+
if (!await publicKeyFromRaw(rawKey).verify(payload, sigBytes)) {
|
|
314
|
+
return { reason: 'malformed-signature' };
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
return { reason: 'malformed-signature' };
|
|
318
|
+
}
|
|
319
|
+
counted.add(signerId);
|
|
320
|
+
approves++;
|
|
321
|
+
}
|
|
322
|
+
return { approves, sawUnknownSigner };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The `{ commit }` operation the claim points at: `blockIds` contains the claimed block and the
|
|
327
|
+
* op's `actionId` / `rev` equal the claim's. Absent → `claim-not-in-message` (the replay stop).
|
|
328
|
+
*/
|
|
329
|
+
function findClaimedCommitOp(message: RepoMessage, claim: ProofClaim): CommitRequest | undefined {
|
|
330
|
+
for (const operation of message.operations) {
|
|
331
|
+
if (operation === null || typeof operation !== 'object' || !('commit' in operation)) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const commit = operation.commit;
|
|
335
|
+
if (commit !== null && typeof commit === 'object'
|
|
336
|
+
&& Array.isArray(commit.blockIds)
|
|
337
|
+
&& commit.blockIds.includes(claim.blockId)
|
|
338
|
+
&& commit.actionId === claim.actionId
|
|
339
|
+
&& commit.rev === claim.rev) {
|
|
340
|
+
return commit;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|