@freedomofpress/cometbft 0.1.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/.github/workflows/auto-bump.yml +81 -0
- package/.github/workflows/publish.yml +60 -0
- package/.github/workflows/test.yml +56 -0
- package/Readme.md +54 -0
- package/buf.gen.yaml +12 -0
- package/buf.yaml +4 -0
- package/dist/.gitkeep +0 -0
- package/eslint.config.js +31 -0
- package/localnet.sh +219 -0
- package/package.json +39 -0
- package/src/commit.ts +210 -0
- package/src/encoding.ts +37 -0
- package/src/lightclient.ts +214 -0
- package/src/tests/commit.test.ts +278 -0
- package/src/tests/encoding.test.ts +45 -0
- package/src/tests/fixtures/commit-12.json +64 -0
- package/src/tests/fixtures/validators-12.json +41 -0
- package/src/tests/fixtures/webcat.json +71 -0
- package/src/tests/lightclient.test.ts +317 -0
- package/src/tests/validators.test.ts +238 -0
- package/src/tests/webcat.test.ts +114 -0
- package/src/types.ts +63 -0
- package/src/validators.ts +84 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { importCommit } from "../commit";
|
|
4
|
+
import {
|
|
5
|
+
base64ToUint8Array,
|
|
6
|
+
Uint8ArrayToBase64,
|
|
7
|
+
Uint8ArrayToHex,
|
|
8
|
+
} from "../encoding";
|
|
9
|
+
import { verifyCommit } from "../lightclient";
|
|
10
|
+
import type { CommitJson, ValidatorJson } from "../types";
|
|
11
|
+
import { importValidators } from "../validators";
|
|
12
|
+
import commitFixture from "./fixtures/commit-12.json";
|
|
13
|
+
import validatorsFixture from "./fixtures/validators-12.json";
|
|
14
|
+
|
|
15
|
+
function clone<T>(x: T): T {
|
|
16
|
+
return JSON.parse(JSON.stringify(x));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("lightclient.verifyCommit", () => {
|
|
20
|
+
it("verifies a valid commit against the validator set", async () => {
|
|
21
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
22
|
+
const cResp = commitFixture as unknown as CommitJson;
|
|
23
|
+
|
|
24
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
25
|
+
const sh = importCommit(cResp);
|
|
26
|
+
|
|
27
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
28
|
+
|
|
29
|
+
expect(out.quorum).toBe(true);
|
|
30
|
+
expect(out.ok).toBe(true);
|
|
31
|
+
expect(out.signedPower > 0n).toBe(true);
|
|
32
|
+
expect(out.signedPower <= out.totalPower).toBe(true);
|
|
33
|
+
expect(out.headerTime).toBeDefined();
|
|
34
|
+
expect(out.appHash instanceof Uint8Array).toBe(true);
|
|
35
|
+
expect(out.blockIdHash instanceof Uint8Array).toBe(true);
|
|
36
|
+
expect(out.unknownValidators.length).toBe(0);
|
|
37
|
+
expect(out.invalidSignatures.length).toBe(0);
|
|
38
|
+
expect(out.countedSignatures).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("fails quorum and invalidates all signatures when block_id.hash is tampered", async () => {
|
|
42
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
43
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
44
|
+
|
|
45
|
+
const badCommit = clone(commitFixture) as any;
|
|
46
|
+
const h: string = badCommit.signed_header.commit.block_id.hash;
|
|
47
|
+
badCommit.signed_header.commit.block_id.hash =
|
|
48
|
+
h.slice(0, -2) + (h.slice(-2) === "00" ? "01" : "00");
|
|
49
|
+
|
|
50
|
+
const sh = importCommit(badCommit as CommitJson);
|
|
51
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
52
|
+
|
|
53
|
+
expect(out.quorum).toBe(false);
|
|
54
|
+
expect(out.invalidSignatures.length).toBe(out.countedSignatures);
|
|
55
|
+
expect(out.ok).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("drops below 2/3 quorum when two votes are ABSENT", async () => {
|
|
59
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
60
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
61
|
+
|
|
62
|
+
const lowPower = clone(commitFixture) as any;
|
|
63
|
+
for (let i = 0; i < 2; i++) {
|
|
64
|
+
lowPower.signed_header.commit.signatures[i].block_id_flag = 1;
|
|
65
|
+
lowPower.signed_header.commit.signatures[i].signature = "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sh = importCommit(lowPower as CommitJson);
|
|
69
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
70
|
+
|
|
71
|
+
expect(out.quorum).toBe(false);
|
|
72
|
+
expect(out.ok).toBe(false);
|
|
73
|
+
expect(out.invalidSignatures.length).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps quorum when one signature is corrupted and reports it as invalid", async () => {
|
|
77
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
78
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
79
|
+
|
|
80
|
+
const badSigResp = clone(commitFixture) as any;
|
|
81
|
+
const sigB64: string =
|
|
82
|
+
badSigResp.signed_header.commit.signatures[0].signature;
|
|
83
|
+
const sigBytes = base64ToUint8Array(sigB64);
|
|
84
|
+
sigBytes[0] ^= 0x01;
|
|
85
|
+
badSigResp.signed_header.commit.signatures[0].signature =
|
|
86
|
+
Uint8ArrayToBase64(sigBytes);
|
|
87
|
+
|
|
88
|
+
const sh = importCommit(badSigResp as CommitJson);
|
|
89
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
90
|
+
|
|
91
|
+
expect(out.quorum).toBe(true);
|
|
92
|
+
expect(out.invalidSignatures.length).toBe(1);
|
|
93
|
+
expect(out.ok).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("adds 0 power when a validator's votingPower is undefined but signature is valid", async () => {
|
|
97
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
98
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
99
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
100
|
+
|
|
101
|
+
const vsetZeroOne = {
|
|
102
|
+
validators: vset.validators.map((vv, i) =>
|
|
103
|
+
i === 0 ? { ...vv, votingPower: undefined as any } : vv,
|
|
104
|
+
),
|
|
105
|
+
proposer: vset.proposer,
|
|
106
|
+
totalVotingPower: vset.totalVotingPower, // still 4n
|
|
107
|
+
} as any;
|
|
108
|
+
|
|
109
|
+
const out = await verifyCommit(sh, vsetZeroOne, cryptoIndex);
|
|
110
|
+
|
|
111
|
+
expect(out.countedSignatures).toBe(4);
|
|
112
|
+
expect(out.invalidSignatures.length).toBe(0);
|
|
113
|
+
expect(out.signedPower).toBe(out.totalPower - 1n); // 3n of 4n
|
|
114
|
+
expect(out.quorum).toBe(true);
|
|
115
|
+
expect(out.ok).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("throws when SignedHeader is missing header/commit", async () => {
|
|
119
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
120
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
121
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
122
|
+
|
|
123
|
+
delete (sh as any).header;
|
|
124
|
+
await expect(verifyCommit(sh as any, vset, cryptoIndex)).rejects.toThrow(
|
|
125
|
+
/SignedHeader missing header\/commit/i,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("throws on header/commit height mismatch", async () => {
|
|
130
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
131
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
132
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
133
|
+
(sh.commit as any).height = 13n;
|
|
134
|
+
await expect(verifyCommit(sh, vset, cryptoIndex)).rejects.toThrow(
|
|
135
|
+
/height mismatch/i,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws when validator set is empty", async () => {
|
|
140
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
141
|
+
const { proto: vset0, cryptoIndex } = await importValidators(vResp);
|
|
142
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
143
|
+
const vset = { ...vset0, validators: [] };
|
|
144
|
+
await expect(verifyCommit(sh, vset, cryptoIndex)).rejects.toThrow(
|
|
145
|
+
/no validators/i,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("throws when validator set totalVotingPower is missing (defaults to 0n)", async () => {
|
|
150
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
151
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
152
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
153
|
+
|
|
154
|
+
const vsetMissing = {
|
|
155
|
+
validators: vset.validators,
|
|
156
|
+
proposer: vset.proposer,
|
|
157
|
+
totalVotingPower: undefined as any, // triggers ?? 0n path
|
|
158
|
+
} as any;
|
|
159
|
+
|
|
160
|
+
await expect(verifyCommit(sh, vsetMissing, cryptoIndex)).rejects.toThrow(
|
|
161
|
+
/total power must be positive/i,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("throws when total voting power is non-positive", async () => {
|
|
166
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
167
|
+
const { proto: vset0, cryptoIndex } = await importValidators(vResp);
|
|
168
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
169
|
+
const vset = { ...vset0, totalVotingPower: 0n };
|
|
170
|
+
await expect(verifyCommit(sh, vset, cryptoIndex)).rejects.toThrow(
|
|
171
|
+
/total power/i,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("throws on duplicate validator address in the set", async () => {
|
|
176
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
177
|
+
const { proto: vset0, cryptoIndex } = await importValidators(vResp);
|
|
178
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
179
|
+
const dup = vset0.validators[0];
|
|
180
|
+
const vset = { ...vset0, validators: [...vset0.validators, dup] };
|
|
181
|
+
await expect(verifyCommit(sh, vset, cryptoIndex)).rejects.toThrow(
|
|
182
|
+
/duplicate validator address/i,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("throws when commit BlockID is missing", async () => {
|
|
187
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
188
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
189
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
190
|
+
(sh.commit as any).blockId = undefined;
|
|
191
|
+
await expect(verifyCommit(sh, vset, cryptoIndex)).rejects.toThrow(
|
|
192
|
+
/missing blockid/i,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("throws when PartSetHeader is missing or malformed", async () => {
|
|
197
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
198
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
199
|
+
|
|
200
|
+
const sh1 = importCommit(commitFixture as unknown as CommitJson);
|
|
201
|
+
(sh1.commit!.blockId as any).partSetHeader = undefined;
|
|
202
|
+
await expect(verifyCommit(sh1, vset, cryptoIndex)).rejects.toThrow(
|
|
203
|
+
/partsetheader is missing/i,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const sh2 = importCommit(commitFixture as unknown as CommitJson);
|
|
207
|
+
(sh2.commit!.blockId!.partSetHeader as any).hash = new Uint8Array(0);
|
|
208
|
+
await expect(verifyCommit(sh2, vset, cryptoIndex)).rejects.toThrow(
|
|
209
|
+
/partsetheader hash is missing/i,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("throws when PartSetHeader.total is invalid", async () => {
|
|
214
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
215
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
216
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
217
|
+
(sh.commit!.blockId!.partSetHeader as any).total = -1;
|
|
218
|
+
await expect(verifyCommit(sh, vset, cryptoIndex)).rejects.toThrow(
|
|
219
|
+
/total is invalid/i,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("collects unknown validator addresses without counting them", async () => {
|
|
224
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
225
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
226
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
227
|
+
|
|
228
|
+
const u = new Uint8Array(20);
|
|
229
|
+
u.fill(0xff);
|
|
230
|
+
sh.commit!.signatures[0].validatorAddress = u;
|
|
231
|
+
|
|
232
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
233
|
+
|
|
234
|
+
expect(out.quorum).toBe(true);
|
|
235
|
+
expect(out.ok).toBe(true);
|
|
236
|
+
expect(out.unknownValidators.length).toBe(1);
|
|
237
|
+
expect(out.invalidSignatures.length).toBe(0);
|
|
238
|
+
expect(out.countedSignatures).toBe(3);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("marks a COMMIT with empty signature as invalid and does not count it", async () => {
|
|
242
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
243
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
244
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
245
|
+
|
|
246
|
+
sh.commit!.signatures[1].signature = new Uint8Array(0);
|
|
247
|
+
|
|
248
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
249
|
+
|
|
250
|
+
expect(out.invalidSignatures.length).toBe(1);
|
|
251
|
+
expect(out.countedSignatures).toBe(3);
|
|
252
|
+
expect(out.quorum).toBe(true);
|
|
253
|
+
expect(out.ok).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("keeps quorum when one verify() call throws (caught) and marks that vote invalid", async () => {
|
|
257
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
258
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
259
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
260
|
+
|
|
261
|
+
const originalVerify = crypto.subtle.verify.bind(crypto.subtle) as (
|
|
262
|
+
...args: Parameters<SubtleCrypto["verify"]>
|
|
263
|
+
) => ReturnType<SubtleCrypto["verify"]>;
|
|
264
|
+
|
|
265
|
+
let calls = 0;
|
|
266
|
+
|
|
267
|
+
(crypto.subtle as any).verify = (
|
|
268
|
+
...args: Parameters<SubtleCrypto["verify"]>
|
|
269
|
+
): ReturnType<SubtleCrypto["verify"]> => {
|
|
270
|
+
calls += 1;
|
|
271
|
+
if (calls === 1) {
|
|
272
|
+
return Promise.reject(new Error("forced verify error")) as ReturnType<
|
|
273
|
+
SubtleCrypto["verify"]
|
|
274
|
+
>;
|
|
275
|
+
}
|
|
276
|
+
return originalVerify(...args);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
281
|
+
expect(out.quorum).toBe(true);
|
|
282
|
+
expect(out.ok).toBe(true);
|
|
283
|
+
expect(out.invalidSignatures.length).toBe(1);
|
|
284
|
+
expect(out.countedSignatures).toBe(4);
|
|
285
|
+
} finally {
|
|
286
|
+
(crypto.subtle as any).verify = originalVerify;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("treats a known validator without a crypto key as invalid but still counts the vote attempt", async () => {
|
|
291
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
292
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
293
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
294
|
+
|
|
295
|
+
const addrHex = Uint8ArrayToHex(
|
|
296
|
+
sh.commit!.signatures[0].validatorAddress,
|
|
297
|
+
).toUpperCase();
|
|
298
|
+
cryptoIndex.delete(addrHex);
|
|
299
|
+
|
|
300
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
301
|
+
|
|
302
|
+
expect(out.quorum).toBe(true);
|
|
303
|
+
expect(out.ok).toBe(true);
|
|
304
|
+
expect(out.invalidSignatures.includes(addrHex)).toBe(true);
|
|
305
|
+
expect(out.countedSignatures).toBe(4);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("throws when BlockID hash is empty", async () => {
|
|
309
|
+
const vResp = validatorsFixture as unknown as ValidatorJson;
|
|
310
|
+
const { proto: vset, cryptoIndex } = await importValidators(vResp);
|
|
311
|
+
const sh = importCommit(commitFixture as unknown as CommitJson);
|
|
312
|
+
(sh.commit!.blockId as any).hash = new Uint8Array(0);
|
|
313
|
+
await expect(verifyCommit(sh, vset, cryptoIndex)).rejects.toThrow(
|
|
314
|
+
/blockid hash is missing/i,
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
base64ToUint8Array,
|
|
5
|
+
Uint8ArrayToBase64,
|
|
6
|
+
Uint8ArrayToHex,
|
|
7
|
+
} from "../encoding";
|
|
8
|
+
import type { ValidatorJson } from "../types";
|
|
9
|
+
import { importValidators } from "../validators";
|
|
10
|
+
import validatorsFixture from "./fixtures/validators-12.json";
|
|
11
|
+
|
|
12
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
13
|
+
|
|
14
|
+
async function sha256(u8: Uint8Array): Promise<Uint8Array> {
|
|
15
|
+
const buf = await crypto.subtle.digest("SHA-256", new Uint8Array(u8));
|
|
16
|
+
return new Uint8Array(buf);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function filledBytes(seed: number, len = 32): Uint8Array {
|
|
20
|
+
const u = new Uint8Array(len);
|
|
21
|
+
u.fill(seed & 0xff);
|
|
22
|
+
// small variation so different seeds give different keys
|
|
23
|
+
if (len > 0) u[0] = (seed * 17) & 0xff;
|
|
24
|
+
if (len > 1) u[len - 1] = (seed * 29) & 0xff;
|
|
25
|
+
return u;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function makeValidatorEntry(
|
|
29
|
+
pub: Uint8Array,
|
|
30
|
+
power: number,
|
|
31
|
+
opts?: { lowercaseAddr?: boolean; keyType?: string },
|
|
32
|
+
) {
|
|
33
|
+
const keyType = opts?.keyType ?? "tendermint/PubKeyEd25519";
|
|
34
|
+
const addr = Uint8ArrayToHex((await sha256(pub)).slice(0, 20));
|
|
35
|
+
return {
|
|
36
|
+
address: opts?.lowercaseAddr ? addr : addr.toUpperCase(),
|
|
37
|
+
pub_key: { type: keyType, value: Uint8ArrayToBase64(pub) },
|
|
38
|
+
voting_power: String(power),
|
|
39
|
+
proposer_priority: "0",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeResponse(
|
|
44
|
+
entries: any[],
|
|
45
|
+
height = "12",
|
|
46
|
+
countOverride?: string,
|
|
47
|
+
totalOverride?: string,
|
|
48
|
+
): ValidatorJson {
|
|
49
|
+
const count = countOverride ?? String(entries.length);
|
|
50
|
+
const total = totalOverride ?? String(entries.length);
|
|
51
|
+
return {
|
|
52
|
+
block_height: height,
|
|
53
|
+
validators: entries,
|
|
54
|
+
count,
|
|
55
|
+
total,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// ---------------------- tests -------------------------
|
|
59
|
+
|
|
60
|
+
describe("importValidators (browser crypto)", () => {
|
|
61
|
+
it("happy path: imports full single-page set and sums power", async () => {
|
|
62
|
+
const v1 = await makeValidatorEntry(filledBytes(1), 1);
|
|
63
|
+
const v2 = await makeValidatorEntry(filledBytes(2), 2);
|
|
64
|
+
const v3 = await makeValidatorEntry(filledBytes(3), 3);
|
|
65
|
+
const v4 = await makeValidatorEntry(filledBytes(4), 4);
|
|
66
|
+
const resp = makeResponse([v1, v2, v3, v4], "42");
|
|
67
|
+
|
|
68
|
+
const out = await importValidators(resp);
|
|
69
|
+
|
|
70
|
+
expect(out.proto.validators).toHaveLength(4);
|
|
71
|
+
expect(out.proto.totalVotingPower).toBe(10n); // 1+2+3+4
|
|
72
|
+
|
|
73
|
+
// cryptoIndex should have 4 entries keyed by uppercase hex
|
|
74
|
+
expect(out.cryptoIndex.size).toBe(4);
|
|
75
|
+
for (const [addrHex, key] of out.cryptoIndex.entries()) {
|
|
76
|
+
expect(addrHex).toMatch(/^[0-9A-F]{40}$/);
|
|
77
|
+
expect(key.type).toBe("public");
|
|
78
|
+
expect((key.algorithm as EcKeyAlgorithm).name).toBe("Ed25519");
|
|
79
|
+
expect(key.usages).toEqual(["verify"]);
|
|
80
|
+
expect(key.extractable).toBe(false);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// proto validators should have bytes + bigint fields
|
|
84
|
+
for (const pv of out.proto.validators) {
|
|
85
|
+
expect(pv.address instanceof Uint8Array).toBe(true);
|
|
86
|
+
expect(pv.address.length).toBe(20);
|
|
87
|
+
expect(typeof pv.votingPower).toBe("bigint");
|
|
88
|
+
expect(pv.votingPower).toBeGreaterThanOrEqual(1n);
|
|
89
|
+
expect(pv.pubKeyType).toBe("ed25519");
|
|
90
|
+
expect(pv.pubKeyBytes instanceof Uint8Array).toBe(true);
|
|
91
|
+
expect(pv.pubKeyBytes.length).toBe(32);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("accepts lowercase input addresses but normalizes keys to uppercase in cryptoIndex", async () => {
|
|
96
|
+
const v1 = await makeValidatorEntry(filledBytes(10), 1, {
|
|
97
|
+
lowercaseAddr: true,
|
|
98
|
+
});
|
|
99
|
+
const v2 = await makeValidatorEntry(filledBytes(11), 1, {
|
|
100
|
+
lowercaseAddr: true,
|
|
101
|
+
});
|
|
102
|
+
const resp = makeResponse([v1, v2], "99");
|
|
103
|
+
|
|
104
|
+
const out = await importValidators(resp);
|
|
105
|
+
|
|
106
|
+
// Derive expected addresses (uppercase) from pubkeys and confirm present in cryptoIndex
|
|
107
|
+
for (const e of resp.validators) {
|
|
108
|
+
const pubRaw = base64ToUint8Array(e.pub_key.value);
|
|
109
|
+
const derived = Uint8ArrayToHex(
|
|
110
|
+
(await sha256(pubRaw)).slice(0, 20),
|
|
111
|
+
).toUpperCase();
|
|
112
|
+
expect(out.cryptoIndex.has(derived)).toBe(true);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("throws when validators array is empty", async () => {
|
|
117
|
+
const resp = {
|
|
118
|
+
jsonrpc: "2.0",
|
|
119
|
+
id: -1,
|
|
120
|
+
result: {
|
|
121
|
+
block_height: "12",
|
|
122
|
+
validators: [], // <-- empty
|
|
123
|
+
count: "0",
|
|
124
|
+
total: "0",
|
|
125
|
+
},
|
|
126
|
+
} as any;
|
|
127
|
+
|
|
128
|
+
await expect(importValidators(resp)).rejects.toThrow(/Missing validators/i);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("throws on invalid address (wrong length/format)", async () => {
|
|
132
|
+
const pub = filledBytes(1);
|
|
133
|
+
const good = await makeValidatorEntry(pub, 1);
|
|
134
|
+
const bad = { ...good, address: good.address.slice(0, 39) }; // 39 chars
|
|
135
|
+
const resp = makeResponse([bad, good], "7");
|
|
136
|
+
await expect(importValidators(resp)).rejects.toThrow(/address.*40/i);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws on invalid pub_key object", async () => {
|
|
140
|
+
const v1 = await makeValidatorEntry(filledBytes(1), 1);
|
|
141
|
+
const bad = { ...v1, pub_key: { type: "tendermint/PubKeyEd25519" } }; // missing value
|
|
142
|
+
const resp = makeResponse([bad, v1], "7");
|
|
143
|
+
await expect(importValidators(resp)).rejects.toThrow(
|
|
144
|
+
/key object is invalid/i,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("throws on unsupported pub_key.type", async () => {
|
|
149
|
+
const bad = await makeValidatorEntry(filledBytes(1), 1, {
|
|
150
|
+
keyType: "tendermint/PubKeySecp256k1",
|
|
151
|
+
});
|
|
152
|
+
const v2 = await makeValidatorEntry(filledBytes(2), 1);
|
|
153
|
+
const resp = makeResponse([bad, v2], "7");
|
|
154
|
+
await expect(importValidators(resp)).rejects.toThrow(/unsupported/i);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("throws on wrong pubkey length (31 bytes)", async () => {
|
|
158
|
+
const pub31 = filledBytes(5, 31); // 31 bytes
|
|
159
|
+
const vBad = await makeValidatorEntry(pub31, 1);
|
|
160
|
+
const v2 = await makeValidatorEntry(filledBytes(2), 1);
|
|
161
|
+
const resp = makeResponse([vBad, v2], "7");
|
|
162
|
+
await expect(importValidators(resp)).rejects.toThrow();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("throws on address/pubkey mismatch", async () => {
|
|
166
|
+
const v1 = await makeValidatorEntry(filledBytes(1), 1);
|
|
167
|
+
const v2 = await makeValidatorEntry(filledBytes(2), 1);
|
|
168
|
+
(v2 as any).address = v1.address; // duplicate/mismatch
|
|
169
|
+
const resp = makeResponse([v1, v2], "7");
|
|
170
|
+
await expect(importValidators(resp)).rejects.toThrow(
|
|
171
|
+
/does not match its public key|mismatch/i,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("throws on duplicate address", async () => {
|
|
176
|
+
const pub = filledBytes(9);
|
|
177
|
+
const v1 = await makeValidatorEntry(pub, 1);
|
|
178
|
+
const v2 = await makeValidatorEntry(pub, 1); // same address
|
|
179
|
+
const resp = makeResponse([v1, v2], "7");
|
|
180
|
+
await expect(importValidators(resp)).rejects.toThrow(/Duplicate entry/i);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("throws on invalid voting power (non-integer / <1)", async () => {
|
|
184
|
+
const v1 = await makeValidatorEntry(filledBytes(1), 0); // invalid: zero
|
|
185
|
+
const v2 = await makeValidatorEntry(filledBytes(2), 1);
|
|
186
|
+
const resp = makeResponse([v1, v2], "7");
|
|
187
|
+
await expect(importValidators(resp)).rejects.toThrow(
|
|
188
|
+
/Invalid voting power/i,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("validators fixture:", () => {
|
|
194
|
+
it("derives address as SHA-256(pubkey)[0..20] (uppercased)", async () => {
|
|
195
|
+
const resp = validatorsFixture as unknown as ValidatorJson;
|
|
196
|
+
|
|
197
|
+
for (const entry of resp.validators) {
|
|
198
|
+
const pubRaw = base64ToUint8Array(entry.pub_key.value);
|
|
199
|
+
const derived = Uint8ArrayToHex(
|
|
200
|
+
(await sha256(pubRaw)).slice(0, 20),
|
|
201
|
+
).toUpperCase();
|
|
202
|
+
expect(derived).toBe(entry.address.toUpperCase());
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("imports Ed25519 keys and fills proto validators correctly", async () => {
|
|
207
|
+
const resp = validatorsFixture as unknown as ValidatorJson;
|
|
208
|
+
|
|
209
|
+
const out = await importValidators(resp);
|
|
210
|
+
|
|
211
|
+
expect(out.proto.validators).toHaveLength(4);
|
|
212
|
+
expect(out.proto.totalVotingPower).toBe(4n);
|
|
213
|
+
|
|
214
|
+
// cryptoIndex: correct WebCrypto attributes
|
|
215
|
+
for (const key of out.cryptoIndex.values()) {
|
|
216
|
+
expect(key.type).toBe("public");
|
|
217
|
+
expect((key.algorithm as EcKeyAlgorithm).name).toBe("Ed25519");
|
|
218
|
+
expect(key.usages).toEqual(["verify"]);
|
|
219
|
+
expect(key.extractable).toBe(false);
|
|
220
|
+
await expect(crypto.subtle.exportKey("raw", key)).rejects.toBeTruthy();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// proto validators content matches derived bytes
|
|
224
|
+
for (const e of resp.validators) {
|
|
225
|
+
const pubRaw = base64ToUint8Array(e.pub_key.value);
|
|
226
|
+
const sha = await sha256(pubRaw);
|
|
227
|
+
const addr20 = sha.slice(0, 20);
|
|
228
|
+
|
|
229
|
+
const match = out.proto.validators.find(
|
|
230
|
+
(pv) => Uint8ArrayToHex(pv.address) === Uint8ArrayToHex(addr20),
|
|
231
|
+
);
|
|
232
|
+
expect(match).toBeTruthy();
|
|
233
|
+
expect(match!.pubKeyType).toBe("ed25519");
|
|
234
|
+
expect(Uint8ArrayToHex(match!.pubKeyBytes)).toBe(Uint8ArrayToHex(pubRaw));
|
|
235
|
+
expect(match!.votingPower).toBe(1n);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { importCommit } from "../commit";
|
|
4
|
+
import { Uint8ArrayToBase64, Uint8ArrayToHex } from "../encoding";
|
|
5
|
+
import { verifyCommit } from "../lightclient";
|
|
6
|
+
import type { CommitJson, ValidatorJson } from "../types";
|
|
7
|
+
import { importValidators } from "../validators";
|
|
8
|
+
import blockFixture from "./fixtures/webcat.json";
|
|
9
|
+
|
|
10
|
+
function clone<T>(x: T): T {
|
|
11
|
+
return JSON.parse(JSON.stringify(x));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("lightclient.verifyCommit", () => {
|
|
15
|
+
it("verifies a valid commit against the validator set", async () => {
|
|
16
|
+
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
17
|
+
const commit = blockFixture as unknown as CommitJson;
|
|
18
|
+
|
|
19
|
+
const { proto: vset, cryptoIndex } = await importValidators(validators);
|
|
20
|
+
const sh = importCommit(commit);
|
|
21
|
+
|
|
22
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
23
|
+
|
|
24
|
+
expect(out.quorum).toBe(true);
|
|
25
|
+
expect(out.ok).toBe(true);
|
|
26
|
+
expect(out.signedPower > 0n).toBe(true);
|
|
27
|
+
expect(out.signedPower <= out.totalPower).toBe(true);
|
|
28
|
+
expect(out.headerTime).toBeDefined();
|
|
29
|
+
expect(out.appHash instanceof Uint8Array).toBe(true);
|
|
30
|
+
expect(out.blockIdHash instanceof Uint8Array).toBe(true);
|
|
31
|
+
expect(out.unknownValidators.length).toBe(0);
|
|
32
|
+
expect(out.invalidSignatures.length).toBe(0);
|
|
33
|
+
expect(out.countedSignatures).toBeGreaterThan(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("flags invalid signatures", async () => {
|
|
37
|
+
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
38
|
+
const commit = clone(blockFixture) as unknown as CommitJson;
|
|
39
|
+
|
|
40
|
+
// Flip one byte of the BlockID hash to keep the signature well-formed but
|
|
41
|
+
// cryptographically invalid for the mutated sign-bytes.
|
|
42
|
+
commit.signed_header.commit.block_id.hash =
|
|
43
|
+
"3A1D00CC2A092465E85EA2C24986BEE0105285039DC1873BB6B0CA7F610EC89D";
|
|
44
|
+
|
|
45
|
+
const { proto: vset, cryptoIndex } = await importValidators(validators);
|
|
46
|
+
const sh = importCommit(commit);
|
|
47
|
+
|
|
48
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
49
|
+
|
|
50
|
+
expect(out.quorum).toBe(false);
|
|
51
|
+
expect(out.ok).toBe(false);
|
|
52
|
+
expect(out.signedPower).toBe(0n);
|
|
53
|
+
expect(out.invalidSignatures).toEqual([
|
|
54
|
+
Uint8ArrayToHex(vset.validators[0].address).toUpperCase(),
|
|
55
|
+
]);
|
|
56
|
+
expect(out.countedSignatures).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("flags invalid signatures", async () => {
|
|
60
|
+
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
61
|
+
const commit = clone(blockFixture) as unknown as CommitJson;
|
|
62
|
+
|
|
63
|
+
commit.signed_header.commit.signatures[0].signature = Uint8ArrayToBase64(
|
|
64
|
+
new Uint8Array(64),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const { proto: vset, cryptoIndex } = await importValidators(validators);
|
|
68
|
+
const sh = importCommit(commit);
|
|
69
|
+
|
|
70
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
71
|
+
|
|
72
|
+
expect(out.quorum).toBe(false);
|
|
73
|
+
expect(out.ok).toBe(false);
|
|
74
|
+
expect(out.signedPower).toBe(0n);
|
|
75
|
+
expect(out.invalidSignatures).toEqual([
|
|
76
|
+
Uint8ArrayToHex(vset.validators[0].address).toUpperCase(),
|
|
77
|
+
]);
|
|
78
|
+
expect(out.countedSignatures).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects malformed signature bytes", async () => {
|
|
82
|
+
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
83
|
+
const commit = clone(blockFixture) as unknown as CommitJson;
|
|
84
|
+
|
|
85
|
+
// Truncate the signature (must be 64 bytes for Ed25519)
|
|
86
|
+
commit.signed_header.commit.signatures[0].signature = "AA==";
|
|
87
|
+
|
|
88
|
+
await expect(async () => importCommit(commit)).rejects.toThrow(
|
|
89
|
+
/signature must be 64 bytes/,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reports unknown validators", async () => {
|
|
94
|
+
const validators = blockFixture.validator_set as unknown as ValidatorJson;
|
|
95
|
+
const commit = clone(blockFixture) as unknown as CommitJson;
|
|
96
|
+
|
|
97
|
+
commit.signed_header.commit.signatures[0].validator_address =
|
|
98
|
+
"0000000000000000000000000000000000000000";
|
|
99
|
+
|
|
100
|
+
const { proto: vset, cryptoIndex } = await importValidators(validators);
|
|
101
|
+
const sh = importCommit(commit);
|
|
102
|
+
|
|
103
|
+
const out = await verifyCommit(sh, vset, cryptoIndex);
|
|
104
|
+
|
|
105
|
+
expect(out.quorum).toBe(false);
|
|
106
|
+
expect(out.ok).toBe(false);
|
|
107
|
+
expect(out.signedPower).toBe(0n);
|
|
108
|
+
expect(out.invalidSignatures).toEqual([]);
|
|
109
|
+
expect(out.unknownValidators).toEqual([
|
|
110
|
+
"0000000000000000000000000000000000000000",
|
|
111
|
+
]);
|
|
112
|
+
expect(out.countedSignatures).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
});
|