aere-contracts/test/proof-aggregator-inner-vkey-enforcement-2026-07-17.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
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.
2026-07-20 01:02:37 +03:00

425 lines
20 KiB
JavaScript

// AereProofAggregatorV2 — regression tests for INNER VKEYS COUNTED BUT NEVER ENFORCED.
//
// FINDING (2026-07-17 sweep, code-read): AereProofAggregator 0x6a260238…F84C5 pins the
// aggregation vkey and the guest is genuinely recursive — but the contract only COUNTED how
// many folded inner vkeys were registered (into a `recognized` field) and recorded the
// aggregation regardless. An unregistered inner program was not an error, just a lower number.
//
// THE ATTACK: an attacker writes an SP1 guest with NO constraints —
// fn main() { sp1_zkvm::io::commit_slice(&attacker_chosen_bytes); }
// — has it commit the exact AereZKScreen public tuple, proves it (trivial: it asserts nothing),
// folds it with the REAL aggregation guest (which honestly verifies it, because it IS a valid
// SP1 proof of that vacuous program), and submits. V1 records an aggregation that `exists`,
// carries the pinned aggVKey, and is indistinguishable from a genuine one except for
// `recognized < count` — a field nothing ever enforced.
//
// Each attack test is PAIRED with a control on the REAL V1 contract showing the same call
// SUCCEEDS on V1, i.e. the bug was real and V2 closes it.
//
// THE GUEST IS UNCHANGED. The composite the guest commits ALREADY binds every inner vkey
// digest, and V1 already recomputed it correctly — so the contract always KNEW which programs
// were folded. The fix is purely what the contract DOES with that knowledge. This is verified
// below: the composite recomputed by these tests reproduces the REAL live on-chain aggregation
// digest 0x8e246396…4e36 byte-for-byte.
//
// The SP1 verifier is MOCKED (MockSp1Verifier: reverts unless the exact (vkey, publicValues,
// proof) triple was pre-registered — mirroring the real gateway). That is the CORRECT model:
// the attacker's aggregation proof is CRYPTOGRAPHICALLY VALID (the recursion honestly verified
// a real proof of a real — if vacuous — program), so the gateway accepts it. The bug is not a
// broken proof; it is a proof about an unvetted program.
const { expect } = require("chai");
const { ethers } = require("hardhat");
const PROOF = "0x4388a21c1234"; // opaque; the mock keys acceptance on the triple
/* ---------------- REAL live values (chain 2800, read 2026-07-17) ------------------ */
// The pinned aggregation program (registered on the live V1).
const AGG_VKEY = "0x003a70854f258b13c6612b0272421f2d7d97f90eee63e124864c80be61a2672c";
// The three REAL registered inner programs: KoalaBear digest (what the recursion binds)
// + BN254 on-chain vkey (what is published) + label.
const REAL_INNERS = [
{
label: "zkscreen",
digest: "0x3510880a6bb8ae42705a4cd76f3aa69422a8d836122361ba12a913635de3e5b7",
onChain: "0x006a211015aee2b90b82d266bef3aa6944551b06c488d86e895489b1dde3e5b7",
pvHash: "0xe4d363f0108963cb592f1c592d855a46b6d9579cccef2d4689b93c2441c78d88",
},
{
label: "over18",
digest: "0x2d54a563446f19740eab67524f3622a67463b716375c230a30d2b0fe633343c5",
onChain: "0x005aa94ac711bc65d0755b3a94f3622a6e8c76e2cdd708c29869587f633343c5",
pvHash: "0x44d77dcf2f60c490ffad39fb2cbe75648ae3c402ce6cbe1ac29f6525af3c6ce6",
},
{
label: "zkml-mnist",
digest: "0x30a21e711cd157425f9b5e2203ea0a525cb86cbc625b720f3313ce3727c4bceb",
onChain: "0x0061443ce273455d0afcdaf1103ea0a52b970d979896dc83d989e71ba7c4bceb",
pvHash: "0xa04ed64252a5388bee0a757a0c2f83da7594cfaee6c7c25e6b873f46f43aad7e",
},
];
// The composite the LIVE contract actually recorded for those three (aggregationCount==2).
const REAL_COMPOSITE = "0x8e246396a2f40c837501d06c965163db644284ffc877d7cd0d38681b612b4e36";
const FOUNDATION = "0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3"; // live V1 owner
// The attacker's unconstrained guest: a real SP1 program, so it has a real vkey digest —
// just one that was never vetted or registered.
const ATTACKER_DIGEST = "0x" + "de".repeat(32);
// composite = sha256( u32_be(n) || (digest_i || pvHash_i)... ) — the guest's formula.
function composite(digests, pvHashes) {
let packed = ethers.solidityPacked(["uint32"], [digests.length]);
for (let i = 0; i < digests.length; i++) packed = ethers.concat([packed, digests[i], pvHashes[i]]);
return ethers.sha256(packed);
}
function marker(vkey, pv, proof) {
return ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof]));
}
describe("AereProofAggregatorV2 — inner-vkey enforcement fix (2026-07-17)", function () {
let mock, v1, v2, owner, attacker;
const digests = REAL_INNERS.map((p) => p.digest);
const pvHashes = REAL_INNERS.map((p) => p.pvHash);
async function accept(vkey, pv, proof = PROOF) {
await mock.setValid(marker(vkey, pv, proof), true);
}
async function deployV2(seal = false) {
return await (await ethers.getContractFactory("AereProofAggregatorV2")).deploy(
await mock.getAddress(),
seal ? ethers.ZeroAddress : owner.address,
[AGG_VKEY],
["recursive-aggregation agg-program (real, live)"],
digests,
REAL_INNERS.map((p) => p.onChain),
REAL_INNERS.map((p) => p.label),
seal
);
}
beforeEach(async function () {
[owner, attacker] = await ethers.getSigners();
mock = await (await ethers.getContractFactory("MockSp1Verifier")).deploy();
await mock.waitForDeployment();
// The REAL V1 contract, registered EXACTLY as the live one is — the control.
v1 = await (await ethers.getContractFactory("AereProofAggregator")).deploy(await mock.getAddress());
await v1.waitForDeployment();
await v1.registerAggProgram(AGG_VKEY, "recursive-aggregation agg-program (real, live)");
for (const p of REAL_INNERS) await v1.registerInnerProgram(p.digest, p.onChain, p.label);
v2 = await deployV2(false);
await v2.waitForDeployment();
});
/* ---------------- the guest is unchanged: composite reproduces live data --------- */
describe("the recursion guest is UNCHANGED (the composite already bound the inner vkeys)", function () {
it("recomputes the REAL live on-chain composite 0x8e246396…4e36 byte-for-byte", async function () {
expect(composite(digests, pvHashes)).to.equal(REAL_COMPOSITE);
});
it("V2's on-chain _composite matches the guest formula on the REAL live inner set", async function () {
await accept(AGG_VKEY, REAL_COMPOSITE);
// verifyAggregation returns the composite it recomputed on-chain
expect(await v2.verifyAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF)).to.equal(REAL_COMPOSITE);
});
it("V1 recomputed it correctly too — V1 always KNEW which programs were folded", async function () {
await accept(AGG_VKEY, REAL_COMPOSITE);
expect(await v1.verifyAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF)).to.equal(REAL_COMPOSITE);
});
});
/* ================================================================================
THE ATTACK: fold a proof of an UNCONSTRAINED, unregistered guest.
================================================================================ */
describe("ATTACK — unconstrained attacker guest folded into an aggregation", function () {
// The attacker's vacuous guest commits the EXACT zkscreen public tuple (so its pvHash is
// the real zkscreen one), but the program that produced it is the attacker's own.
const atkDigests = () => [ATTACKER_DIGEST];
const atkPvHashes = () => [REAL_INNERS[0].pvHash]; // the real AereZKScreen public-values hash
const atkComposite = () => composite(atkDigests(), atkPvHashes());
it("PAIRED CONTROL: V1 RECORDS the attacker's aggregation as if legitimate", async function () {
const c = atkComposite();
await accept(AGG_VKEY, c);
await expect(v1.connect(attacker).recordAggregation(AGG_VKEY, atkDigests(), atkPvHashes(), c, PROOF)).to.emit(
v1,
"AggregationVerified"
);
const rec = await v1.getAggregation(c);
expect(rec[0]).to.equal(true); // exists <-- a forged aggregation is ON THE BOOKS
expect(rec[1]).to.equal(AGG_VKEY); // carries the pinned, legitimate agg vkey
expect(rec[2]).to.equal(1); // count
expect(rec[3]).to.equal(0); // recognized == 0: the ONLY tell, and nothing enforced it
expect(await v1.aggregationCount()).to.equal(1n);
});
it("V2 REVERTS with UnregisteredInnerProgram — the attack is closed", async function () {
const c = atkComposite();
await accept(AGG_VKEY, c);
await expect(v2.connect(attacker).recordAggregation(AGG_VKEY, atkDigests(), atkPvHashes(), c, PROOF))
.to.be.revertedWithCustomError(v2, "UnregisteredInnerProgram")
.withArgs(ATTACKER_DIGEST, 0);
expect((await v2.getAggregation(c))[0]).to.equal(false); // nothing recorded
expect(await v2.aggregationCount()).to.equal(0n);
});
it("V2's verifyAggregation view REVERTS too (a view used as an oracle must bind)", async function () {
const c = atkComposite();
await accept(AGG_VKEY, c);
await expect(v2.verifyAggregation(AGG_VKEY, atkDigests(), atkPvHashes(), c, PROOF))
.to.be.revertedWithCustomError(v2, "UnregisteredInnerProgram")
.withArgs(ATTACKER_DIGEST, 0);
});
it("PAIRED CONTROL: V1's verifyAggregation view ACCEPTS the attacker's fold", async function () {
const c = atkComposite();
await accept(AGG_VKEY, c);
expect(await v1.verifyAggregation(AGG_VKEY, atkDigests(), atkPvHashes(), c, PROOF)).to.equal(c);
});
});
describe("ATTACK — attacker guest SMUGGLED IN alongside real registered programs", function () {
// Harder to spot: 3 real programs + 1 attacker program. V1 reports recognized=3, count=4.
const mixDigests = () => [...digests, ATTACKER_DIGEST];
const mixPvHashes = () => [...pvHashes, "0x" + "77".repeat(32)];
const mixComposite = () => composite(mixDigests(), mixPvHashes());
it("PAIRED CONTROL: V1 records it (recognized=3 of count=4) — looks almost legitimate", async function () {
const c = mixComposite();
await accept(AGG_VKEY, c);
await v1.connect(attacker).recordAggregation(AGG_VKEY, mixDigests(), mixPvHashes(), c, PROOF);
const rec = await v1.getAggregation(c);
expect(rec[0]).to.equal(true);
expect(rec[2]).to.equal(4); // count
expect(rec[3]).to.equal(3); // recognized — 3 real programs carry the forged 4th
});
it("V2 REVERTS at the attacker's index (3), rejecting the whole aggregation", async function () {
const c = mixComposite();
await accept(AGG_VKEY, c);
await expect(v2.connect(attacker).recordAggregation(AGG_VKEY, mixDigests(), mixPvHashes(), c, PROOF))
.to.be.revertedWithCustomError(v2, "UnregisteredInnerProgram")
.withArgs(ATTACKER_DIGEST, 3);
expect(await v2.aggregationCount()).to.equal(0n);
});
});
describe("ATTACK — zero inner vkey digest (default value of an unregistered slot)", function () {
it("V2 REVERTS: a zero digest can never be allowed, so the gate cannot be no-op'd", async function () {
const d = [ethers.ZeroHash];
const h = [REAL_INNERS[0].pvHash];
const c = composite(d, h);
await accept(AGG_VKEY, c);
await expect(v2.recordAggregation(AGG_VKEY, d, h, c, PROOF))
.to.be.revertedWithCustomError(v2, "UnregisteredInnerProgram")
.withArgs(ethers.ZeroHash, 0);
});
it("V2 refuses to REGISTER a zero digest (would turn the gate into a no-op)", async function () {
await expect(v2.registerInnerProgram(ethers.ZeroHash, "0x" + "11".repeat(32), "evil"))
.to.be.revertedWithCustomError(v2, "ZeroVKey");
});
it("PAIRED CONTROL: V1 HAPPILY registers a zero digest", async function () {
await expect(v1.registerInnerProgram(ethers.ZeroHash, "0x" + "11".repeat(32), "evil")).to.not.be.reverted;
expect((await v1.innerPrograms(ethers.ZeroHash))[0]).to.equal(true);
});
});
/* ---------------- V2 does not over-reject: the honest path still works ----------- */
describe("V2 does not over-reject — real aggregations still record", function () {
it("records the REAL 3-program aggregation, recognized == count (the V2 invariant)", async function () {
await accept(AGG_VKEY, REAL_COMPOSITE);
await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF)).to.emit(
v2,
"AggregationVerified"
);
const rec = await v2.getAggregation(REAL_COMPOSITE);
expect(rec[0]).to.equal(true);
expect(rec[1]).to.equal(AGG_VKEY);
expect(rec[2]).to.equal(3); // count
expect(rec[3]).to.equal(3); // recognized == count, ALWAYS, by construction
expect(rec[5]).to.equal(owner.address);
expect(await v2.aggregationCount()).to.equal(1n);
});
it("seeds the allowed set at deploy — functional with NO post-deploy signature", async function () {
for (const p of REAL_INNERS) {
const r = await v2.innerPrograms(p.digest);
expect(r[0]).to.equal(true);
expect(r[1]).to.equal(p.onChain);
expect(r[2]).to.equal(p.label);
}
expect(await v2.isAggProgram(AGG_VKEY)).to.equal(true);
expect(await v2.owner()).to.equal(owner.address);
expect(await v2.SP1_VERIFIER()).to.equal(await mock.getAddress());
});
it("areInnerProgramsAllowed() previews the gate without spending gas", async function () {
expect(await v2.areInnerProgramsAllowed(digests)).to.equal(true);
expect(await v2.areInnerProgramsAllowed([...digests, ATTACKER_DIGEST])).to.equal(false);
});
it("a newly-registered program becomes foldable (the governed extension path)", async function () {
const NEWD = "0x" + "5a".repeat(32);
const d = [NEWD];
const h = ["0x" + "99".repeat(32)];
const c = composite(d, h);
await accept(AGG_VKEY, c);
await expect(v2.recordAggregation(AGG_VKEY, d, h, c, PROOF)).to.be.revertedWithCustomError(
v2,
"UnregisteredInnerProgram"
);
await v2.registerInnerProgram(NEWD, "0x" + "6b".repeat(32), "new-program");
await expect(v2.recordAggregation(AGG_VKEY, d, h, c, PROOF)).to.emit(v2, "AggregationVerified");
});
});
/* ---------------- inherited protections still hold ------------------------------ */
describe("inherited protections (unchanged from V1)", function () {
it("an unregistered AGGREGATION program still reverts", async function () {
const bad = "0x" + "01".repeat(32);
await expect(v2.recordAggregation(bad, digests, pvHashes, REAL_COMPOSITE, PROOF))
.to.be.revertedWithCustomError(v2, "UnknownAggProgram")
.withArgs(bad);
});
it("a composite that does not match the committed public values reverts", async function () {
const wrong = "0x" + "ee".repeat(32);
await accept(AGG_VKEY, wrong);
await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, wrong, PROOF)).to.be.revertedWithCustomError(
v2,
"CompositeMismatch"
);
});
it("an invalid Groth16 proof reverts InvalidProof", async function () {
await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF))
.to.be.revertedWithCustomError(v2, "InvalidProof");
});
it("dropping a folded child changes the composite => rejected", async function () {
await accept(AGG_VKEY, REAL_COMPOSITE);
await expect(
v2.recordAggregation(AGG_VKEY, digests.slice(0, 2), pvHashes.slice(0, 2), REAL_COMPOSITE, PROOF)
).to.be.revertedWithCustomError(v2, "CompositeMismatch");
});
it("empty / mismatched-length / wrong-pv-length all revert", async function () {
await expect(v2.recordAggregation(AGG_VKEY, [], [], REAL_COMPOSITE, PROOF)).to.be.revertedWithCustomError(
v2,
"EmptyAggregation"
);
await expect(
v2.recordAggregation(AGG_VKEY, digests, pvHashes.slice(0, 2), REAL_COMPOSITE, PROOF)
).to.be.revertedWithCustomError(v2, "LengthMismatch");
await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, "0x1234", PROOF))
.to.be.revertedWithCustomError(v2, "BadPublicValuesLength")
.withArgs(2);
});
});
/* ---------------- registry governance + the seal latch -------------------------- */
describe("registry governance and the one-way seal", function () {
it("only the owner may register", async function () {
await expect(
v2.connect(attacker).registerInnerProgram("0x" + "5a".repeat(32), "0x" + "6b".repeat(32), "evil")
).to.be.revertedWith("Ownable: caller is not the owner");
await expect(v2.connect(attacker).registerAggProgram("0x" + "5a".repeat(32), "evil")).to.be.revertedWith(
"Ownable: caller is not the owner"
);
});
it("sealRegistry() permanently freezes the set and renounces ownership", async function () {
await expect(v2.sealRegistry()).to.emit(v2, "RegistrySealed");
expect(await v2.registrySealed()).to.equal(true);
expect(await v2.owner()).to.equal(ethers.ZeroAddress);
// no one can extend the set any more — not even the old owner
await expect(
v2.registerInnerProgram("0x" + "5a".repeat(32), "0x" + "6b".repeat(32), "post-seal")
).to.be.revertedWith("Ownable: caller is not the owner");
await expect(v2.sealRegistry()).to.be.revertedWith("Ownable: caller is not the owner");
});
it("sealed aggregations still record (sealing freezes the SET, not the contract)", async function () {
await v2.sealRegistry();
await accept(AGG_VKEY, REAL_COMPOSITE);
await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF)).to.emit(
v2,
"AggregationVerified"
);
});
it("can be deployed SEALED at construction: seeded set, zero admin, ever", async function () {
const sealed = await deployV2(true);
await sealed.waitForDeployment();
expect(await sealed.registrySealed()).to.equal(true);
expect(await sealed.owner()).to.equal(ethers.ZeroAddress);
expect(await sealed.isAggProgram(AGG_VKEY)).to.equal(true);
expect((await sealed.innerPrograms(REAL_INNERS[0].digest))[0]).to.equal(true);
// and it still rejects the attack
const c = composite([ATTACKER_DIGEST], [REAL_INNERS[0].pvHash]);
await accept(AGG_VKEY, c);
await expect(
sealed.recordAggregation(AGG_VKEY, [ATTACKER_DIGEST], [REAL_INNERS[0].pvHash], c, PROOF)
).to.be.revertedWithCustomError(sealed, "UnregisteredInnerProgram");
});
});
/* ---------------- constructor guards -------------------------------------------- */
describe("constructor guards", function () {
let F;
beforeEach(async function () {
F = await ethers.getContractFactory("AereProofAggregatorV2");
});
it("rejects a code-less SP1 verifier (V1 accepted one)", async function () {
await expect(
F.deploy("0x000000000000000000000000000000000000dEaD", owner.address, [], [], [], [], [], false)
).to.be.revertedWithCustomError(F, "VerifierHasNoCode");
});
it("rejects the zero verifier / zero owner when not sealed", async function () {
await expect(F.deploy(ethers.ZeroAddress, owner.address, [], [], [], [], [], false)).to.be.revertedWithCustomError(
F,
"ZeroAddress"
);
await expect(
F.deploy(await mock.getAddress(), ethers.ZeroAddress, [], [], [], [], [], false)
).to.be.revertedWithCustomError(F, "ZeroAddress");
});
it("rejects mismatched seed array lengths", async function () {
const m = await mock.getAddress();
await expect(F.deploy(m, owner.address, [AGG_VKEY], [], [], [], [], false)).to.be.revertedWithCustomError(
F,
"SeedLengthMismatch"
);
await expect(
F.deploy(m, owner.address, [], [], digests, [], [], false)
).to.be.revertedWithCustomError(F, "SeedLengthMismatch");
});
it("rejects seeding a zero inner digest or zero agg vkey", async function () {
const m = await mock.getAddress();
await expect(
F.deploy(m, owner.address, [], [], [ethers.ZeroHash], ["0x" + "11".repeat(32)], ["evil"], false)
).to.be.revertedWithCustomError(F, "ZeroVKey");
await expect(
F.deploy(m, owner.address, [ethers.ZeroHash], ["evil"], [], [], [], false)
).to.be.revertedWithCustomError(F, "ZeroVKey");
});
});
});