@freedomofpress/cometbft 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lightclient.js +112 -130
- package/dist/lightclient.js.map +1 -1
- package/dist/tests/fixtures/webcat.json +53 -23
- package/dist/tests/lightclient.test.js +5 -12
- package/dist/tests/lightclient.test.js.map +1 -1
- package/dist/tests/webcat.test.js +114 -9
- package/dist/tests/webcat.test.js.map +1 -1
- package/package.json +2 -1
- package/src/lightclient.ts +150 -178
- package/src/tests/fixtures/header-merkle-webcat-02.json +101 -0
- package/src/tests/fixtures/webcat.json +53 -23
- package/src/tests/lightclient.test.ts +5 -20
- package/src/tests/webcat.test.ts +157 -16
|
@@ -5,42 +5,54 @@
|
|
|
5
5
|
"block": "11",
|
|
6
6
|
"app": "0"
|
|
7
7
|
},
|
|
8
|
-
"chain_id": "test-
|
|
9
|
-
"height": "
|
|
10
|
-
"time": "
|
|
8
|
+
"chain_id": "webcat-test-02",
|
|
9
|
+
"height": "18720",
|
|
10
|
+
"time": "2026-03-08T03:00:52.980342151Z",
|
|
11
11
|
"last_block_id": {
|
|
12
|
-
"hash": "
|
|
12
|
+
"hash": "8271A9B3A0689FF0CB095E39175403A74BC374ABBE23184B20D902199FDC0BA7",
|
|
13
13
|
"parts": {
|
|
14
14
|
"total": 1,
|
|
15
|
-
"hash": "
|
|
15
|
+
"hash": "AEB84D5BAB13692BCBD9603B9A94AA310203648DF16A755EA2BA9A2A1D667BCB"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
|
-
"last_commit_hash": "
|
|
18
|
+
"last_commit_hash": "9E3394940C1C94504E0465CCED1CE7047639BDF5506E34EA07FE729C5E4EA5FC",
|
|
19
19
|
"data_hash": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
|
20
|
-
"validators_hash": "
|
|
21
|
-
"next_validators_hash": "
|
|
20
|
+
"validators_hash": "166D75D14EE1555615C57D2F013454152214BDB143D458C00A02B2AC0F2AC128",
|
|
21
|
+
"next_validators_hash": "166D75D14EE1555615C57D2F013454152214BDB143D458C00A02B2AC0F2AC128",
|
|
22
22
|
"consensus_hash": "048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F",
|
|
23
|
-
"app_hash": "
|
|
23
|
+
"app_hash": "6068FFC66BC857FE76B4E8A779176ABBA6CBFC3F119F961915AF01C293EDC9D3",
|
|
24
24
|
"last_results_hash": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
|
25
25
|
"evidence_hash": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
|
26
|
-
"proposer_address": "
|
|
26
|
+
"proposer_address": "B734AC6CC1F14AAC8315FE8DBB07402ED4E45D72"
|
|
27
27
|
},
|
|
28
28
|
"commit": {
|
|
29
|
-
"height": "
|
|
29
|
+
"height": "18720",
|
|
30
30
|
"round": 0,
|
|
31
31
|
"block_id": {
|
|
32
|
-
"hash": "
|
|
32
|
+
"hash": "1E1B52E922F1E20D8113D38A70675BFB0CF83609C00CD6CE637FC79B0C73A890",
|
|
33
33
|
"parts": {
|
|
34
34
|
"total": 1,
|
|
35
|
-
"hash": "
|
|
35
|
+
"hash": "CC7EAC61FA5FB28ADC4F26BEB76CD54CDB66C2A952E81CBEE33B55D0ED1B0A78"
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"signatures": [
|
|
39
39
|
{
|
|
40
40
|
"block_id_flag": 2,
|
|
41
|
-
"validator_address": "
|
|
42
|
-
"timestamp": "
|
|
43
|
-
"signature": "
|
|
41
|
+
"validator_address": "48926DF1CC10DBBFAA78953F9BE38E59020DE01D",
|
|
42
|
+
"timestamp": "2026-03-08T03:01:53.984641899Z",
|
|
43
|
+
"signature": "V1swep5n+4AX155lTrNXSyS7k0L5Wnx2E8zrBGqWEDHf8pW5tFO1ron1xHqfMmJh9BSK3FaOWnIVToqcrvCiBg=="
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"block_id_flag": 2,
|
|
47
|
+
"validator_address": "A39CFEBE6E8CA10B6101C46884A6EB8E62D04417",
|
|
48
|
+
"timestamp": "2026-03-08T03:01:54.033082245Z",
|
|
49
|
+
"signature": "VLIvwTw5aC15Oq8AkmiXudyIFKIo3VBOgU1eIQN+JxgsNtJwr8tuZsaGf6ds7BY6kQ8E93pxDm1CUk2pYrrZDQ=="
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"block_id_flag": 2,
|
|
53
|
+
"validator_address": "B734AC6CC1F14AAC8315FE8DBB07402ED4E45D72",
|
|
54
|
+
"timestamp": "2026-03-08T03:01:54.025260639Z",
|
|
55
|
+
"signature": "ODjl80UkQB7uBQmf0TUCbgg+p8Mvv9ihvmbyYou3YWB7nqcwp5tzz8MiFkoPo8zfiLL+83AqKeYm7S3y3kPxDQ=="
|
|
44
56
|
}
|
|
45
57
|
]
|
|
46
58
|
}
|
|
@@ -48,24 +60,42 @@
|
|
|
48
60
|
"validator_set": {
|
|
49
61
|
"validators": [
|
|
50
62
|
{
|
|
51
|
-
"address": "
|
|
63
|
+
"address": "48926DF1CC10DBBFAA78953F9BE38E59020DE01D",
|
|
64
|
+
"pub_key": {
|
|
65
|
+
"type": "tendermint/PubKeyEd25519",
|
|
66
|
+
"value": "zEK+pyAEhhCaoHYhhT+rrorJoc9kZbfY+JkbMtF8u5Y="
|
|
67
|
+
},
|
|
68
|
+
"power": "1",
|
|
69
|
+
"name": null
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"address": "A39CFEBE6E8CA10B6101C46884A6EB8E62D04417",
|
|
73
|
+
"pub_key": {
|
|
74
|
+
"type": "tendermint/PubKeyEd25519",
|
|
75
|
+
"value": "8dZUej72UriKCPIDvk01c6yLaAB2ssYIli3fT3jKwSM="
|
|
76
|
+
},
|
|
77
|
+
"power": "1",
|
|
78
|
+
"name": null
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"address": "B734AC6CC1F14AAC8315FE8DBB07402ED4E45D72",
|
|
52
82
|
"pub_key": {
|
|
53
83
|
"type": "tendermint/PubKeyEd25519",
|
|
54
|
-
"value": "
|
|
84
|
+
"value": "WBK6Af3uKtC+VTv8x48f2TPoNnsebSXSBTIsJku/nQw="
|
|
55
85
|
},
|
|
56
|
-
"power": "
|
|
86
|
+
"power": "1",
|
|
57
87
|
"name": null
|
|
58
88
|
}
|
|
59
89
|
],
|
|
60
90
|
"proposer": {
|
|
61
|
-
"address": "
|
|
91
|
+
"address": "B734AC6CC1F14AAC8315FE8DBB07402ED4E45D72",
|
|
62
92
|
"pub_key": {
|
|
63
93
|
"type": "tendermint/PubKeyEd25519",
|
|
64
|
-
"value": "
|
|
94
|
+
"value": "WBK6Af3uKtC+VTv8x48f2TPoNnsebSXSBTIsJku/nQw="
|
|
65
95
|
},
|
|
66
|
-
"power": "
|
|
96
|
+
"power": "1",
|
|
67
97
|
"name": null
|
|
68
98
|
},
|
|
69
|
-
"total_voting_power": "
|
|
99
|
+
"total_voting_power": "3"
|
|
70
100
|
}
|
|
71
101
|
}
|
|
@@ -38,7 +38,7 @@ describe("lightclient.verifyCommit", () => {
|
|
|
38
38
|
expect(out.countedSignatures).toBeGreaterThan(0);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it("
|
|
41
|
+
it("fails quorum and invalidates all signatures when block_id.hash is tampered", async () => {
|
|
42
42
|
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
43
43
|
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
44
44
|
|
|
@@ -48,26 +48,11 @@ describe("lightclient.verifyCommit", () => {
|
|
|
48
48
|
h.slice(0, -2) + (h.slice(-2) === "00" ? "01" : "00");
|
|
49
49
|
|
|
50
50
|
const sh = importCommit(badCommit as CommitJson);
|
|
51
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("throws when app_hash is tampered", async () => {
|
|
58
|
-
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
59
|
-
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
60
|
-
|
|
61
|
-
const badHeader = clone(commitFixture) as any;
|
|
62
|
-
const appHash: string = badHeader.signed_header.header.app_hash;
|
|
63
|
-
badHeader.signed_header.header.app_hash =
|
|
64
|
-
appHash.slice(0, -2) + (appHash.slice(-2) === "00" ? "01" : "00");
|
|
65
|
-
|
|
66
|
-
const sh = importCommit(badHeader as CommitJson);
|
|
67
|
-
|
|
68
|
-
await expect(verifyCommit(sh, vset, cryptoIndex)).rejects.toThrow(
|
|
69
|
-
/header hash does not match commit blockid hash/i,
|
|
70
|
-
);
|
|
53
|
+
expect(out.quorum).toBe(false);
|
|
54
|
+
expect(out.invalidSignatures.length).toBe(out.countedSignatures);
|
|
55
|
+
expect(out.ok).toBe(false);
|
|
71
56
|
});
|
|
72
57
|
|
|
73
58
|
it("drops below 2/3 quorum when two votes are ABSENT", async () => {
|
package/src/tests/webcat.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { importCommit } from "../commit";
|
|
4
|
-
import { Uint8ArrayToBase64 } from "../encoding";
|
|
4
|
+
import { Uint8ArrayToBase64, Uint8ArrayToHex } from "../encoding";
|
|
5
5
|
import { verifyCommit } from "../lightclient";
|
|
6
6
|
import type { CommitJson, ValidatorJson } from "../types";
|
|
7
7
|
import { importValidators } from "../validators";
|
|
@@ -11,35 +11,61 @@ function clone<T>(x: T): T {
|
|
|
11
11
|
return JSON.parse(JSON.stringify(x));
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function flipLastHexNibble(hex: string): string {
|
|
15
|
+
const last = hex.at(-1);
|
|
16
|
+
if (!last) throw new Error("Cannot mutate an empty hex string");
|
|
17
|
+
|
|
18
|
+
const replacement = last.toLowerCase() === "0" ? "1" : "0";
|
|
19
|
+
return `${hex.slice(0, -1)}${replacement}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
describe("lightclient.verifyCommit", () => {
|
|
15
|
-
it("
|
|
23
|
+
it("verifies a valid commit against the validator set", async () => {
|
|
16
24
|
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
17
25
|
const commit = blockFixture as unknown as CommitJson;
|
|
18
26
|
|
|
19
27
|
const { proto: vset, cryptoIndex } = await importValidators(validators);
|
|
20
28
|
const sh = importCommit(commit);
|
|
21
29
|
|
|
22
|
-
await
|
|
23
|
-
|
|
24
|
-
);
|
|
30
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
31
|
+
|
|
32
|
+
expect(out.quorum).toBe(true);
|
|
33
|
+
expect(out.ok).toBe(true);
|
|
34
|
+
expect(out.signedPower > 0n).toBe(true);
|
|
35
|
+
expect(out.signedPower <= out.totalPower).toBe(true);
|
|
36
|
+
expect(out.headerTime).toBeDefined();
|
|
37
|
+
expect(out.appHash instanceof Uint8Array).toBe(true);
|
|
38
|
+
expect(out.blockIdHash instanceof Uint8Array).toBe(true);
|
|
39
|
+
expect(out.unknownValidators.length).toBe(0);
|
|
40
|
+
expect(out.invalidSignatures.length).toBe(0);
|
|
41
|
+
expect(out.countedSignatures).toBeGreaterThan(0);
|
|
25
42
|
});
|
|
26
43
|
|
|
27
|
-
it("
|
|
44
|
+
it("flags invalid signatures", async () => {
|
|
28
45
|
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
29
46
|
const commit = clone(blockFixture) as unknown as CommitJson;
|
|
30
47
|
|
|
48
|
+
// Flip one byte of the BlockID hash to keep the signature well-formed but
|
|
49
|
+
// cryptographically invalid for the mutated sign-bytes.
|
|
31
50
|
commit.signed_header.commit.block_id.hash =
|
|
32
51
|
"3A1D00CC2A092465E85EA2C24986BEE0105285039DC1873BB6B0CA7F610EC89D";
|
|
33
52
|
|
|
34
53
|
const { proto: vset, cryptoIndex } = await importValidators(validators);
|
|
35
54
|
const sh = importCommit(commit);
|
|
36
55
|
|
|
37
|
-
await
|
|
38
|
-
|
|
56
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
57
|
+
|
|
58
|
+
expect(out.quorum).toBe(false);
|
|
59
|
+
expect(out.ok).toBe(false);
|
|
60
|
+
expect(out.signedPower).toBe(0n);
|
|
61
|
+
expect(out.invalidSignatures).toHaveLength(vset.validators.length);
|
|
62
|
+
expect(out.invalidSignatures).toContain(
|
|
63
|
+
Uint8ArrayToHex(vset.validators[0].address).toUpperCase(),
|
|
39
64
|
);
|
|
65
|
+
expect(out.countedSignatures).toBe(vset.validators.length);
|
|
40
66
|
});
|
|
41
67
|
|
|
42
|
-
it("
|
|
68
|
+
it("flags invalid signatures", async () => {
|
|
43
69
|
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
44
70
|
const commit = clone(blockFixture) as unknown as CommitJson;
|
|
45
71
|
|
|
@@ -50,9 +76,15 @@ describe("lightclient.verifyCommit", () => {
|
|
|
50
76
|
const { proto: vset, cryptoIndex } = await importValidators(validators);
|
|
51
77
|
const sh = importCommit(commit);
|
|
52
78
|
|
|
53
|
-
await
|
|
54
|
-
|
|
55
|
-
);
|
|
79
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
80
|
+
|
|
81
|
+
expect(out.quorum).toBe(false);
|
|
82
|
+
expect(out.ok).toBe(false);
|
|
83
|
+
expect(out.signedPower).toBe(2n);
|
|
84
|
+
expect(out.invalidSignatures).toEqual([
|
|
85
|
+
Uint8ArrayToHex(vset.validators[0].address).toUpperCase(),
|
|
86
|
+
]);
|
|
87
|
+
expect(out.countedSignatures).toBe(vset.validators.length);
|
|
56
88
|
});
|
|
57
89
|
|
|
58
90
|
it("rejects malformed signature bytes", async () => {
|
|
@@ -67,7 +99,7 @@ describe("lightclient.verifyCommit", () => {
|
|
|
67
99
|
);
|
|
68
100
|
});
|
|
69
101
|
|
|
70
|
-
it("
|
|
102
|
+
it("reports unknown validators", async () => {
|
|
71
103
|
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
72
104
|
const commit = clone(blockFixture) as unknown as CommitJson;
|
|
73
105
|
|
|
@@ -77,8 +109,117 @@ describe("lightclient.verifyCommit", () => {
|
|
|
77
109
|
const { proto: vset, cryptoIndex } = await importValidators(validators);
|
|
78
110
|
const sh = importCommit(commit);
|
|
79
111
|
|
|
80
|
-
await
|
|
81
|
-
|
|
82
|
-
);
|
|
112
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
113
|
+
|
|
114
|
+
expect(out.quorum).toBe(false);
|
|
115
|
+
expect(out.ok).toBe(false);
|
|
116
|
+
expect(out.signedPower).toBe(2n);
|
|
117
|
+
expect(out.invalidSignatures).toEqual([]);
|
|
118
|
+
expect(out.unknownValidators).toEqual([
|
|
119
|
+
"0000000000000000000000000000000000000000",
|
|
120
|
+
]);
|
|
121
|
+
expect(out.countedSignatures).toBe(vset.validators.length - 1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("detects tampering of every header field by checking the header merkle root against commit.block_id.hash", async () => {
|
|
125
|
+
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
126
|
+
const { proto: vset, cryptoIndex } = await importValidators(validators);
|
|
127
|
+
|
|
128
|
+
const mutators: Record<string, (commit: any) => void> = {
|
|
129
|
+
"header.version.block": (commit) => {
|
|
130
|
+
commit.signed_header.header.version.block = String(
|
|
131
|
+
BigInt(commit.signed_header.header.version.block) + 1n,
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
"header.version.app": (commit) => {
|
|
135
|
+
commit.signed_header.header.version.app = String(
|
|
136
|
+
BigInt(commit.signed_header.header.version.app) + 1n,
|
|
137
|
+
);
|
|
138
|
+
},
|
|
139
|
+
"header.chain_id": (commit) => {
|
|
140
|
+
commit.signed_header.header.chain_id = `${commit.signed_header.header.chain_id}-tampered`;
|
|
141
|
+
},
|
|
142
|
+
"header.height": (commit) => {
|
|
143
|
+
commit.signed_header.header.height = String(
|
|
144
|
+
BigInt(commit.signed_header.header.height) + 1n,
|
|
145
|
+
);
|
|
146
|
+
commit.signed_header.commit.height = commit.signed_header.header.height;
|
|
147
|
+
},
|
|
148
|
+
"header.time": (commit) => {
|
|
149
|
+
commit.signed_header.header.time = "2026-03-08T03:00:52.980342152Z";
|
|
150
|
+
},
|
|
151
|
+
"header.last_block_id.hash": (commit) => {
|
|
152
|
+
commit.signed_header.header.last_block_id.hash = flipLastHexNibble(
|
|
153
|
+
commit.signed_header.header.last_block_id.hash,
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
"header.last_block_id.parts.total": (commit) => {
|
|
157
|
+
commit.signed_header.header.last_block_id.parts.total += 1;
|
|
158
|
+
},
|
|
159
|
+
"header.last_block_id.parts.hash": (commit) => {
|
|
160
|
+
commit.signed_header.header.last_block_id.parts.hash =
|
|
161
|
+
flipLastHexNibble(
|
|
162
|
+
commit.signed_header.header.last_block_id.parts.hash,
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
"header.last_commit_hash": (commit) => {
|
|
166
|
+
commit.signed_header.header.last_commit_hash = flipLastHexNibble(
|
|
167
|
+
commit.signed_header.header.last_commit_hash,
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
"header.data_hash": (commit) => {
|
|
171
|
+
commit.signed_header.header.data_hash = flipLastHexNibble(
|
|
172
|
+
commit.signed_header.header.data_hash,
|
|
173
|
+
);
|
|
174
|
+
},
|
|
175
|
+
"header.validators_hash": (commit) => {
|
|
176
|
+
commit.signed_header.header.validators_hash = flipLastHexNibble(
|
|
177
|
+
commit.signed_header.header.validators_hash,
|
|
178
|
+
);
|
|
179
|
+
},
|
|
180
|
+
"header.next_validators_hash": (commit) => {
|
|
181
|
+
commit.signed_header.header.next_validators_hash = flipLastHexNibble(
|
|
182
|
+
commit.signed_header.header.next_validators_hash,
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
"header.consensus_hash": (commit) => {
|
|
186
|
+
commit.signed_header.header.consensus_hash = flipLastHexNibble(
|
|
187
|
+
commit.signed_header.header.consensus_hash,
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
"header.app_hash": (commit) => {
|
|
191
|
+
commit.signed_header.header.app_hash = flipLastHexNibble(
|
|
192
|
+
commit.signed_header.header.app_hash,
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
"header.last_results_hash": (commit) => {
|
|
196
|
+
commit.signed_header.header.last_results_hash = flipLastHexNibble(
|
|
197
|
+
commit.signed_header.header.last_results_hash,
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
"header.evidence_hash": (commit) => {
|
|
201
|
+
commit.signed_header.header.evidence_hash = flipLastHexNibble(
|
|
202
|
+
commit.signed_header.header.evidence_hash,
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
"header.proposer_address": (commit) => {
|
|
206
|
+
commit.signed_header.header.proposer_address = flipLastHexNibble(
|
|
207
|
+
commit.signed_header.header.proposer_address,
|
|
208
|
+
);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
for (const [field, mutate] of Object.entries(mutators)) {
|
|
213
|
+
const tampered = clone(blockFixture);
|
|
214
|
+
mutate(tampered);
|
|
215
|
+
|
|
216
|
+
const out = await verifyCommit(
|
|
217
|
+
importCommit(tampered as unknown as CommitJson),
|
|
218
|
+
vset,
|
|
219
|
+
cryptoIndex,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(out.ok, `${field} tampering should be detected`).toBe(false);
|
|
223
|
+
}
|
|
83
224
|
});
|
|
84
225
|
});
|