Aere Network public source. Everything here can be checked against the live chain (chain id 2800, https://rpc.aere.network). Scope note, stated up front rather than buried: consensus on chain 2800 is classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at the signature, precompile, account and transport layers. Nothing here makes the consensus post-quantum, and no document in it should be read as claiming so.
312 lines
15 KiB
JavaScript
312 lines
15 KiB
JavaScript
// AereZkEthLightClientV2 — regression tests for the UNBOUND TRUST ANCHOR in V1's verify().
|
|
//
|
|
// FINDING (2026-07-17 sweep, code-read): AereZkEthLightClient 0x2a2b6D93…29E9d binds the
|
|
// committed gvr + prevCommitteeRoot to its trusted state in `processProof` (SAFE), but its
|
|
// public `verify()` view runs the Groth16 verify and abi.decodes with NO binds at all. A proof
|
|
// is only ever "SOME committee whose root is prevCommitteeRoot signed this update" — the guest
|
|
// is honest but cannot know which committee is Ethereum's REAL one. So an attacker generates a
|
|
// genuinely VALID proof over a sync committee of their OWN keys, self-signs an arbitrary
|
|
// finalized header, and V1's verify() returns it without reverting. Any consumer using verify()
|
|
// as a finality oracle is handed an attacker-chosen Ethereum head.
|
|
//
|
|
// Each attack test below is PAIRED with a control on the REAL V1 contract showing the exact
|
|
// same call SUCCEEDS on V1 — i.e. the bug was real, and V2 is what closes it.
|
|
//
|
|
// The SP1 verifier is MOCKED (MockSp1Verifier: verifyProof reverts unless the exact
|
|
// (vkey, publicValues, proof) triple was pre-registered — mirroring the real gateway, which
|
|
// reverts on an invalid proof). That is the CORRECT model of this attack: the attacker's proof
|
|
// is CRYPTOGRAPHICALLY VALID (the guest ran honestly over the attacker's own committee), so the
|
|
// gateway accepts it. The bug is not a broken proof — it is a proof about the wrong anchor.
|
|
// This is exactly how the sibling AereOutboundVerifierV2 finding was proven, where a REAL
|
|
// Groth16 proof over a forged validator set was accepted by the REAL live gateway.
|
|
const { expect } = require("chai");
|
|
const { ethers } = require("hardhat");
|
|
|
|
const VKEY = "0x" + "ab".repeat(32);
|
|
const PROOF = "0x1234567890"; // opaque proof bytes; the mock keys acceptance on the triple
|
|
|
|
// EXACTLY the guest's public-values order:
|
|
// abi.encode(bytes32 gvr, bytes32 prevCommitteeRoot, uint64 finalizedSlot,
|
|
// bytes32 finalizedHeaderRoot, bytes32 nextCommitteeRoot, uint64 participation)
|
|
const PV_TYPES = ["bytes32", "bytes32", "uint64", "bytes32", "bytes32", "uint64"];
|
|
function encodePV({ gvr, prevCommitteeRoot, finalizedSlot, finalizedHeaderRoot, nextCommitteeRoot, participation }) {
|
|
return ethers.AbiCoder.defaultAbiCoder().encode(PV_TYPES, [
|
|
gvr, prevCommitteeRoot, finalizedSlot, finalizedHeaderRoot, nextCommitteeRoot, participation,
|
|
]);
|
|
}
|
|
function marker(vkey, pv, proof) {
|
|
return ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof]));
|
|
}
|
|
|
|
// REAL live constants from AereZkEthLightClient 0x2a2b6D93…29E9d (read on chain 2800,
|
|
// 2026-07-17): the mainnet genesis validators root, and the committee/slot the live client
|
|
// had ALREADY rotated to after its one real proof (updateCount == 1).
|
|
const GVR = "0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95";
|
|
const TRUSTED_COMMITTEE = "0x68874d30c32a0ed42a8d59c93ff42a1e8b93f038dc0286c6cb8f25de72ae6c96"; // period 1802
|
|
const BOOT_SLOT = 14753792n;
|
|
const BOOT_HEADER = "0x0d62648b76db75112a550630e3c9087ff7746e76e8774c8a61ed3f3a410b57d7";
|
|
|
|
// The attacker's OWN 512-key sync committee: its SSZ root is whatever they choose. This is the
|
|
// unbound value V1's verify() never checked.
|
|
const ATTACKER_COMMITTEE = "0x" + "de".repeat(32);
|
|
const ATTACKER_HEADER = "0x" + "ba".repeat(32); // arbitrary "finalized" Ethereum head
|
|
const NEXT_COMMITTEE = "0x" + "77".repeat(32);
|
|
|
|
describe("AereZkEthLightClientV2 — unbound-verify() anchor fix (2026-07-17)", function () {
|
|
let mock, v1, v2;
|
|
|
|
function pv(over = {}) {
|
|
return encodePV({
|
|
gvr: GVR,
|
|
prevCommitteeRoot: TRUSTED_COMMITTEE,
|
|
finalizedSlot: 14761984n,
|
|
finalizedHeaderRoot: "0x" + "22".repeat(32),
|
|
nextCommitteeRoot: NEXT_COMMITTEE,
|
|
participation: 512n,
|
|
...over,
|
|
});
|
|
}
|
|
async function accept(publicValues, proof = PROOF) {
|
|
await mock.setValid(marker(VKEY, publicValues, proof), true);
|
|
}
|
|
|
|
beforeEach(async function () {
|
|
mock = await (await ethers.getContractFactory("MockSp1Verifier")).deploy();
|
|
await mock.waitForDeployment();
|
|
const args = [await mock.getAddress(), VKEY, GVR, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER];
|
|
// The REAL V1 contract, deployed with the SAME args — the control.
|
|
v1 = await (await ethers.getContractFactory("AereZkEthLightClient")).deploy(...args);
|
|
await v1.waitForDeployment();
|
|
v2 = await (await ethers.getContractFactory("AereZkEthLightClientV2")).deploy(...args);
|
|
await v2.waitForDeployment();
|
|
});
|
|
|
|
/* ------------------------ bootstrap / lineage preservation ------------------------ */
|
|
|
|
it("bootstraps immutably from the LIVE V1 lineage (vkey, gvr, rotated committee, head)", async function () {
|
|
expect(await v2.SP1_VERIFIER()).to.equal(await mock.getAddress());
|
|
expect(await v2.PROGRAM_VKEY()).to.equal(VKEY);
|
|
expect(await v2.genesisValidatorsRoot()).to.equal(GVR);
|
|
expect(await v2.trustedCommitteeRoot()).to.equal(TRUSTED_COMMITTEE);
|
|
expect(await v2.finalizedSlot()).to.equal(BOOT_SLOT);
|
|
expect(await v2.finalizedHeaderRoot()).to.equal(BOOT_HEADER);
|
|
expect(await v2.updateCount()).to.equal(0n);
|
|
});
|
|
|
|
it("the guest public-values encoding is exactly 192 bytes (6 x 32) — guest UNCHANGED", async function () {
|
|
expect(ethers.dataLength(pv())).to.equal(192);
|
|
});
|
|
|
|
/* =================================================================================
|
|
THE ATTACK: a VALID proof over a PROVER-CHOSEN sync committee.
|
|
================================================================================= */
|
|
|
|
describe("ATTACK — prover-chosen sync committee handed to verify() as an oracle", function () {
|
|
// The attacker's proof: genuinely valid for the vkey (the guest honestly verified a
|
|
// 512/512 BLS aggregate over the ATTACKER'S committee), committing an arbitrary
|
|
// Ethereum finalized header. Only `prevCommitteeRoot` gives it away — and V1 never looked.
|
|
const attackPv = () =>
|
|
encodePV({
|
|
gvr: GVR,
|
|
prevCommitteeRoot: ATTACKER_COMMITTEE, // <-- NOT the committee this client trusts
|
|
finalizedSlot: 99999999n,
|
|
finalizedHeaderRoot: ATTACKER_HEADER,
|
|
nextCommitteeRoot: NEXT_COMMITTEE,
|
|
participation: 512n,
|
|
});
|
|
|
|
it("PAIRED CONTROL: V1.verify() ACCEPTS it and returns the attacker's Ethereum head", async function () {
|
|
const p = attackPv();
|
|
await accept(p);
|
|
// V1 does not revert — it hands back the attacker's chosen finalized header root.
|
|
const out = await v1.verify(p, PROOF);
|
|
expect(out[1]).to.equal(ATTACKER_COMMITTEE); // prevCommitteeRoot: never checked
|
|
expect(out[3]).to.equal(ATTACKER_HEADER); // finalizedHeaderRoot: attacker-chosen
|
|
expect(out[2]).to.equal(99999999n);
|
|
// ...even though the client's REAL trusted committee is something else entirely.
|
|
expect(await v1.trustedCommitteeRoot()).to.equal(TRUSTED_COMMITTEE);
|
|
expect(await v1.trustedCommitteeRoot()).to.not.equal(ATTACKER_COMMITTEE);
|
|
});
|
|
|
|
it("V2.verify() REVERTS with CommitteeMismatch — the attack is closed", async function () {
|
|
const p = attackPv();
|
|
await accept(p);
|
|
await expect(v2.verify(p, PROOF))
|
|
.to.be.revertedWithCustomError(v2, "CommitteeMismatch")
|
|
.withArgs(ATTACKER_COMMITTEE, TRUSTED_COMMITTEE);
|
|
});
|
|
|
|
it("V1.processProof() ALREADY rejected it (the stateful path was never broken)", async function () {
|
|
const p = attackPv();
|
|
await accept(p);
|
|
await expect(v1.processProof(p, PROOF))
|
|
.to.be.revertedWithCustomError(v1, "CommitteeMismatch")
|
|
.withArgs(ATTACKER_COMMITTEE, TRUSTED_COMMITTEE);
|
|
});
|
|
});
|
|
|
|
/* =================================================================================
|
|
THE SAME BUG VIA THE OTHER UNBOUND FIELD: a wrong-network gvr.
|
|
================================================================================= */
|
|
|
|
describe("ATTACK — wrong-network genesis validators root through verify()", function () {
|
|
const WRONG_GVR = "0x" + "99".repeat(32); // e.g. a testnet / attacker-chosen domain
|
|
const attackPv = () => pv({ gvr: WRONG_GVR, finalizedHeaderRoot: ATTACKER_HEADER });
|
|
|
|
it("PAIRED CONTROL: V1.verify() ACCEPTS a proof for a different network's gvr", async function () {
|
|
const p = attackPv();
|
|
await accept(p);
|
|
const out = await v1.verify(p, PROOF);
|
|
expect(out[0]).to.equal(WRONG_GVR);
|
|
expect(out[3]).to.equal(ATTACKER_HEADER);
|
|
expect(await v1.genesisValidatorsRoot()).to.not.equal(WRONG_GVR);
|
|
});
|
|
|
|
it("V2.verify() REVERTS with WrongGenesisValidatorsRoot", async function () {
|
|
const p = attackPv();
|
|
await accept(p);
|
|
await expect(v2.verify(p, PROOF))
|
|
.to.be.revertedWithCustomError(v2, "WrongGenesisValidatorsRoot")
|
|
.withArgs(WRONG_GVR, GVR);
|
|
});
|
|
});
|
|
|
|
describe("ATTACK — zero next committee through verify()", function () {
|
|
const attackPv = () => pv({ nextCommitteeRoot: ethers.ZeroHash });
|
|
|
|
it("PAIRED CONTROL: V1.verify() ACCEPTS a zero next-committee proof", async function () {
|
|
const p = attackPv();
|
|
await accept(p);
|
|
const out = await v1.verify(p, PROOF);
|
|
expect(out[4]).to.equal(ethers.ZeroHash);
|
|
});
|
|
|
|
it("V2.verify() REVERTS with ZeroNextCommittee", async function () {
|
|
const p = attackPv();
|
|
await accept(p);
|
|
await expect(v2.verify(p, PROOF)).to.be.revertedWithCustomError(v2, "ZeroNextCommittee");
|
|
});
|
|
});
|
|
|
|
/* ------------------------------ V2 still accepts the REAL thing ------------------ */
|
|
|
|
describe("V2 does not over-reject — the honest path still works", function () {
|
|
it("verify() ACCEPTS a proof signed by the committee this client trusts", async function () {
|
|
const p = pv();
|
|
await accept(p);
|
|
const out = await v2.verify(p, PROOF);
|
|
expect(out[0]).to.equal(GVR);
|
|
expect(out[1]).to.equal(TRUSTED_COMMITTEE);
|
|
expect(out[2]).to.equal(14761984n);
|
|
expect(out[4]).to.equal(NEXT_COMMITTEE);
|
|
expect(out[5]).to.equal(512n);
|
|
});
|
|
|
|
it("verify() records nothing (pure view: head + updateCount unmoved)", async function () {
|
|
const p = pv();
|
|
await accept(p);
|
|
await v2.verify(p, PROOF);
|
|
expect(await v2.finalizedSlot()).to.equal(BOOT_SLOT);
|
|
expect(await v2.updateCount()).to.equal(0n);
|
|
});
|
|
|
|
it("processProof() ACCEPTS the honest proof, advances the head and rotates the committee", async function () {
|
|
const p = pv();
|
|
await accept(p);
|
|
await expect(v2.processProof(p, PROOF)).to.emit(v2, "FinalityAdvanced");
|
|
expect(await v2.finalizedSlot()).to.equal(14761984n);
|
|
expect(await v2.finalizedHeaderRoot()).to.equal("0x" + "22".repeat(32));
|
|
expect(await v2.trustedCommitteeRoot()).to.equal(NEXT_COMMITTEE); // rotated
|
|
expect(await v2.updateCount()).to.equal(1n);
|
|
});
|
|
|
|
it("verify() tracks the ROTATED committee after processProof (lineage follows the head)", async function () {
|
|
const p = pv();
|
|
await accept(p);
|
|
await v2.processProof(p, PROOF);
|
|
// the once-honest proof is now bound to a committee that has been rotated away from
|
|
await expect(v2.verify(p, PROOF))
|
|
.to.be.revertedWithCustomError(v2, "CommitteeMismatch")
|
|
.withArgs(TRUSTED_COMMITTEE, NEXT_COMMITTEE);
|
|
|
|
// ...and a proof signed by the NEW trusted committee verifies.
|
|
const next = encodePV({
|
|
gvr: GVR,
|
|
prevCommitteeRoot: NEXT_COMMITTEE,
|
|
finalizedSlot: 14770176n,
|
|
finalizedHeaderRoot: "0x" + "44".repeat(32),
|
|
nextCommitteeRoot: "0x" + "55".repeat(32),
|
|
participation: 500n,
|
|
});
|
|
await accept(next);
|
|
const out = await v2.verify(next, PROOF);
|
|
expect(out[1]).to.equal(NEXT_COMMITTEE);
|
|
});
|
|
});
|
|
|
|
/* ------------------------------ proof integrity still enforced ------------------- */
|
|
|
|
describe("proof + encoding integrity (unchanged from V1)", function () {
|
|
it("an unregistered (invalid) proof reverts InvalidProof in processProof", async function () {
|
|
await expect(v2.processProof(pv(), PROOF)).to.be.revertedWithCustomError(v2, "InvalidProof");
|
|
});
|
|
|
|
it("verify() reverts when the gateway rejects the proof", async function () {
|
|
// the mock reverts MockInvalidProof; verify() does not wrap it (view, mirrors V1/QBFT)
|
|
await expect(v2.verify(pv(), PROOF)).to.be.reverted;
|
|
});
|
|
|
|
it("tampered publicValues break the (vkey, pv, proof) triple => rejected", async function () {
|
|
const p = pv();
|
|
await accept(p);
|
|
const tampered = pv({ finalizedHeaderRoot: "0x" + "66".repeat(32) });
|
|
await expect(v2.processProof(tampered, PROOF)).to.be.revertedWithCustomError(v2, "InvalidProof");
|
|
});
|
|
|
|
it("a wrong-length publicValues reverts BadPublicValuesLength", async function () {
|
|
await expect(v2.processProof("0x1234", PROOF))
|
|
.to.be.revertedWithCustomError(v2, "BadPublicValuesLength")
|
|
.withArgs(2);
|
|
await expect(v2.verify("0x1234", PROOF))
|
|
.to.be.revertedWithCustomError(v2, "BadPublicValuesLength")
|
|
.withArgs(2);
|
|
});
|
|
|
|
it("processProof still enforces the monotonic head (StaleFinality)", async function () {
|
|
const stale = pv({ prevCommitteeRoot: TRUSTED_COMMITTEE, finalizedSlot: BOOT_SLOT });
|
|
await accept(stale);
|
|
await expect(v2.processProof(stale, PROOF))
|
|
.to.be.revertedWithCustomError(v2, "StaleFinality")
|
|
.withArgs(BOOT_SLOT, BOOT_SLOT);
|
|
});
|
|
});
|
|
|
|
/* ------------------------------ constructor guards ------------------------------- */
|
|
|
|
describe("constructor guards", function () {
|
|
it("rejects a code-less SP1 verifier (V1 accepted one — silent-accept footgun)", async function () {
|
|
const F = await ethers.getContractFactory("AereZkEthLightClientV2");
|
|
await expect(
|
|
F.deploy("0x000000000000000000000000000000000000dEaD", VKEY, GVR, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER)
|
|
).to.be.revertedWithCustomError(F, "VerifierHasNoCode");
|
|
});
|
|
|
|
it("rejects a zero vkey / zero gvr / zero bootstrap committee", async function () {
|
|
const F = await ethers.getContractFactory("AereZkEthLightClientV2");
|
|
const m = await mock.getAddress();
|
|
await expect(F.deploy(m, ethers.ZeroHash, GVR, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER))
|
|
.to.be.revertedWithCustomError(F, "ZeroValue");
|
|
await expect(F.deploy(m, VKEY, ethers.ZeroHash, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER))
|
|
.to.be.revertedWithCustomError(F, "ZeroValue");
|
|
await expect(F.deploy(m, VKEY, GVR, ethers.ZeroHash, BOOT_SLOT, BOOT_HEADER))
|
|
.to.be.revertedWithCustomError(F, "ZeroValue");
|
|
});
|
|
|
|
it("rejects the zero address", async function () {
|
|
const F = await ethers.getContractFactory("AereZkEthLightClientV2");
|
|
await expect(F.deploy(ethers.ZeroAddress, VKEY, GVR, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER))
|
|
.to.be.revertedWithCustomError(F, "ZeroAddress");
|
|
});
|
|
});
|
|
});
|