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.
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
const { expect } = require("chai");
|
|
const { ethers } = require("hardhat");
|
|
|
|
// FINDING 3 (medium): EVM-validity canonical binding.
|
|
//
|
|
// V1 (AereEVMValidity / AereEVMValidityBatch) records an SP1 postStateRoot as a
|
|
// block WITHOUT binding it to the real chain: no blockHash/parentHash in the
|
|
// public values, no blockhash() check, no inter-record continuity. A genuine SP1
|
|
// proof of a FABRICATED execution can therefore be recorded as "canonical", and
|
|
// first-write-wins dedup lets an attacker squat slot N.
|
|
//
|
|
// This suite (1) reproduces the attack on V1, then (2) proves V2 closes it:
|
|
// - a fabricated proof with a wrong/absent committed blockHash is REJECTED,
|
|
// - a real in-window proof with the correct committed blockHash is accepted,
|
|
// - batch V2 enforces inter-record continuity.
|
|
//
|
|
// The SP1 verifier is mocked (MockSp1Verifier: verifyProof reverts unless the
|
|
// exact (vkey, publicValues, proof) triple was pre-marked valid), and blockhash
|
|
// binding is exercised against real hardhat block hashes.
|
|
|
|
const coder = ethers.AbiCoder.defaultAbiCoder();
|
|
const VKEY = "0x" + "ab".repeat(32);
|
|
const PROOF = "0x4388a21c" + "00".repeat(64); // dummy selector-prefixed bytes
|
|
const ZERO32 = "0x" + "00".repeat(32);
|
|
|
|
function marker(vkey, pv, proof) {
|
|
return ethers.keccak256(
|
|
coder.encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof])
|
|
);
|
|
}
|
|
|
|
// V1 single-block public values: 7 words.
|
|
function encodeV1(o) {
|
|
return coder.encode(
|
|
["uint256", "uint256", "bytes32", "bytes32", "bytes32", "uint256", "uint256"],
|
|
[o.chainId, o.blockNumber, o.prevStateRoot, o.postStateRoot, o.txRoot, o.gasUsed, o.numTxns]
|
|
);
|
|
}
|
|
|
|
// V2 single-block public values: 9 words (adds parentHash + blockHash).
|
|
function encodeV2(o) {
|
|
return coder.encode(
|
|
["uint256", "uint256", "bytes32", "bytes32", "bytes32", "bytes32", "bytes32", "uint256", "uint256"],
|
|
[o.chainId, o.blockNumber, o.parentHash, o.blockHash, o.prevStateRoot, o.postStateRoot, o.txRoot, o.gasUsed, o.numTxns]
|
|
);
|
|
}
|
|
|
|
// V2 batch public values: 10 words.
|
|
function encodeBatchV2(o) {
|
|
return coder.encode(
|
|
["uint256", "uint256", "uint256", "uint256", "bytes32", "bytes32", "bytes32", "bytes32", "uint256", "uint256"],
|
|
[o.chainId, o.firstBlock, o.lastBlock, o.numBlocks, o.firstParentHash, o.lastBlockHash, o.prevStateRoot, o.postStateRoot, o.totalGas, o.numTxns]
|
|
);
|
|
}
|
|
|
|
const FAKE_POST = "0x" + "fa".repeat(32);
|
|
const PREV_ROOT = "0x" + "11".repeat(32);
|
|
const TX_ROOT = "0x" + "22".repeat(32);
|
|
|
|
describe("FINDING 3 - EVM-validity canonical binding", () => {
|
|
let mock, chainId;
|
|
|
|
beforeEach(async () => {
|
|
const M = await ethers.getContractFactory("MockSp1Verifier");
|
|
mock = await M.deploy();
|
|
await mock.waitForDeployment();
|
|
chainId = (await ethers.provider.getNetwork()).chainId; // 31337 on hardhat
|
|
});
|
|
|
|
// ------------------------------------------------------------------ V1 hole
|
|
describe("V1 attack surface (documents the vulnerability)", () => {
|
|
let v1;
|
|
beforeEach(async () => {
|
|
const F = await ethers.getContractFactory("AereEVMValidity");
|
|
v1 = await F.deploy(await mock.getAddress(), VKEY);
|
|
await v1.waitForDeployment();
|
|
});
|
|
|
|
it("records a FABRICATED execution as canonical (no blockhash binding)", async () => {
|
|
// A genuine-for-vkey proof whose committed postStateRoot is fabricated and
|
|
// whose blockNumber never existed as claimed. V1 has no way to tell.
|
|
const pv = encodeV1({
|
|
chainId, blockNumber: 999_000, prevStateRoot: PREV_ROOT,
|
|
postStateRoot: FAKE_POST, txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1,
|
|
});
|
|
await mock.setValid(marker(VKEY, pv, PROOF), true);
|
|
|
|
await v1.recordBlock(pv, PROOF); // ATTACK SUCCEEDS: no revert
|
|
expect(await v1.provenCount()).to.equal(1n);
|
|
const rec = await v1.getRecord(0);
|
|
expect(rec[2]).to.equal(FAKE_POST); // fabricated root anchored as canonical
|
|
});
|
|
|
|
it("lets an attacker SQUAT slot N via first-write-wins", async () => {
|
|
const pvFake = encodeV1({
|
|
chainId, blockNumber: 999_000, prevStateRoot: PREV_ROOT,
|
|
postStateRoot: FAKE_POST, txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1,
|
|
});
|
|
await mock.setValid(marker(VKEY, pvFake, PROOF), true);
|
|
await v1.recordBlock(pvFake, PROOF);
|
|
|
|
// A later, DIFFERENT (even honest) proof for the same height is locked out.
|
|
const pvReal = encodeV1({
|
|
chainId, blockNumber: 999_000, prevStateRoot: PREV_ROOT,
|
|
postStateRoot: "0x" + "cc".repeat(32), txRoot: TX_ROOT, gasUsed: 42000, numTxns: 2,
|
|
});
|
|
await mock.setValid(marker(VKEY, pvReal, PROOF), true);
|
|
await expect(v1.recordBlock(pvReal, PROOF)).to.be.revertedWithCustomError(
|
|
v1, "AlreadyProven"
|
|
);
|
|
});
|
|
});
|
|
|
|
// ------------------------------------------------------------------ V2 fix
|
|
describe("V2 single-block canonical binding", () => {
|
|
let v2;
|
|
beforeEach(async () => {
|
|
const F = await ethers.getContractFactory("AereEVMValidityV2");
|
|
v2 = await F.deploy(await mock.getAddress(), VKEY);
|
|
await v2.waitForDeployment();
|
|
});
|
|
|
|
it("REJECTS a fabricated in-window block whose committed blockHash is wrong", async () => {
|
|
const bn = await ethers.provider.getBlockNumber();
|
|
const parent = await ethers.provider.getBlock(bn - 1);
|
|
const pv = encodeV2({
|
|
chainId, blockNumber: bn,
|
|
parentHash: parent.hash,
|
|
blockHash: "0x" + "de".repeat(32), // WRONG: not the real hash of block bn
|
|
prevStateRoot: PREV_ROOT, postStateRoot: FAKE_POST, txRoot: TX_ROOT,
|
|
gasUsed: 21000, numTxns: 1,
|
|
});
|
|
await mock.setValid(marker(VKEY, pv, PROOF), true);
|
|
await expect(v2.recordBlock(pv, PROOF)).to.be.revertedWithCustomError(
|
|
v2, "BlockHashMismatch"
|
|
);
|
|
});
|
|
|
|
it("REJECTS a block whose hash is unavailable (out of window / future)", async () => {
|
|
const bn = await ethers.provider.getBlockNumber();
|
|
const pv = encodeV2({
|
|
chainId, blockNumber: bn + 5000, // future: blockhash() == 0
|
|
parentHash: ZERO32, blockHash: "0x" + "de".repeat(32),
|
|
prevStateRoot: PREV_ROOT, postStateRoot: FAKE_POST, txRoot: TX_ROOT,
|
|
gasUsed: 21000, numTxns: 1,
|
|
});
|
|
await mock.setValid(marker(VKEY, pv, PROOF), true);
|
|
await expect(v2.recordBlock(pv, PROOF)).to.be.revertedWithCustomError(
|
|
v2, "BlockHashUnavailable"
|
|
);
|
|
});
|
|
|
|
it("ACCEPTS a real in-window proof with the correct committed blockHash", async () => {
|
|
const bn = await ethers.provider.getBlockNumber();
|
|
const blk = await ethers.provider.getBlock(bn);
|
|
const parent = await ethers.provider.getBlock(bn - 1);
|
|
const pv = encodeV2({
|
|
chainId, blockNumber: bn,
|
|
parentHash: parent.hash, blockHash: blk.hash,
|
|
prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "77".repeat(32), txRoot: TX_ROOT,
|
|
gasUsed: 21000, numTxns: 1,
|
|
});
|
|
await mock.setValid(marker(VKEY, pv, PROOF), true); // mines a block
|
|
await v2.recordBlock(pv, PROOF); // bn still in the 256 window here
|
|
expect(await v2.provenCount()).to.equal(1n);
|
|
const rec = await v2.getRecord(0);
|
|
expect(rec[0]).to.equal(BigInt(bn));
|
|
expect(rec[2]).to.equal(blk.hash); // blockHash stored (getRecord field #3)
|
|
});
|
|
|
|
it("REJECTS a wrong committed parentHash even when blockHash is right", async () => {
|
|
const bn = await ethers.provider.getBlockNumber();
|
|
const blk = await ethers.provider.getBlock(bn);
|
|
const pv = encodeV2({
|
|
chainId, blockNumber: bn,
|
|
parentHash: "0x" + "ee".repeat(32), // WRONG parent
|
|
blockHash: blk.hash,
|
|
prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "77".repeat(32), txRoot: TX_ROOT,
|
|
gasUsed: 21000, numTxns: 1,
|
|
});
|
|
await mock.setValid(marker(VKEY, pv, PROOF), true);
|
|
await expect(v2.recordBlock(pv, PROOF)).to.be.revertedWithCustomError(
|
|
v2, "ParentHashMismatch"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("V2 batch continuity + tip binding", () => {
|
|
let b2;
|
|
beforeEach(async () => {
|
|
const F = await ethers.getContractFactory("AereEVMValidityBatchV2");
|
|
b2 = await F.deploy(await mock.getAddress(), VKEY);
|
|
await b2.waitForDeployment();
|
|
});
|
|
|
|
// Bind a batch's tip to a real recent block after mining a margin of blocks,
|
|
// so logical firstBlock..lastBlock ranges never go negative.
|
|
async function freshTip() {
|
|
await ethers.provider.send("hardhat_mine", ["0x14"]); // mine 20 blocks
|
|
const l = await ethers.provider.getBlockNumber();
|
|
const h = (await ethers.provider.getBlock(l)).hash;
|
|
return { l, h };
|
|
}
|
|
|
|
it("REJECTS a batch whose tip blockHash is wrong", async () => {
|
|
const { l } = await freshTip();
|
|
const pv = encodeBatchV2({
|
|
chainId, firstBlock: l - 2, lastBlock: l, numBlocks: 3,
|
|
firstParentHash: ZERO32, lastBlockHash: "0x" + "de".repeat(32), // wrong
|
|
prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "a1".repeat(32),
|
|
totalGas: 63000, numTxns: 3,
|
|
});
|
|
await mock.setValid(marker(VKEY, pv, PROOF), true);
|
|
await expect(b2.recordBatch(pv, PROOF)).to.be.revertedWithCustomError(
|
|
b2, "BlockHashMismatch"
|
|
);
|
|
});
|
|
|
|
it("ACCEPTS a first batch, then ENFORCES continuity on the next", async () => {
|
|
const R1 = "0x" + "a1".repeat(32);
|
|
const R2 = "0x" + "b2".repeat(32);
|
|
|
|
// First batch [la-2 .. la] anchors the chain (no continuity needed yet).
|
|
const a = await freshTip();
|
|
const la = a.l;
|
|
const pvA = encodeBatchV2({
|
|
chainId, firstBlock: la - 2, lastBlock: la, numBlocks: 3,
|
|
firstParentHash: ZERO32, lastBlockHash: a.h,
|
|
prevStateRoot: PREV_ROOT, postStateRoot: R1, totalGas: 63000, numTxns: 3,
|
|
});
|
|
await mock.setValid(marker(VKEY, pvA, PROOF), true);
|
|
await b2.recordBatch(pvA, PROOF);
|
|
expect(await b2.provenCount()).to.equal(1n);
|
|
|
|
// Discontinuous firstBlock (gap) is rejected. lb is far above la+5.
|
|
const g = await freshTip();
|
|
const pvGap = encodeBatchV2({
|
|
chainId, firstBlock: la + 5, lastBlock: g.l, numBlocks: g.l - (la + 5) + 1,
|
|
firstParentHash: ZERO32, lastBlockHash: g.h,
|
|
prevStateRoot: R1, postStateRoot: R2, totalGas: 42000, numTxns: 2,
|
|
});
|
|
await mock.setValid(marker(VKEY, pvGap, PROOF), true);
|
|
await expect(b2.recordBatch(pvGap, PROOF)).to.be.revertedWithCustomError(
|
|
b2, "Discontinuous"
|
|
);
|
|
|
|
// Correct firstBlock but wrong prevStateRoot is rejected.
|
|
const br = await freshTip();
|
|
const pvBadRoot = encodeBatchV2({
|
|
chainId, firstBlock: la + 1, lastBlock: br.l, numBlocks: br.l - (la + 1) + 1,
|
|
firstParentHash: ZERO32, lastBlockHash: br.h,
|
|
prevStateRoot: "0x" + "99".repeat(32), postStateRoot: R2, totalGas: 42000, numTxns: 2,
|
|
});
|
|
await mock.setValid(marker(VKEY, pvBadRoot, PROOF), true);
|
|
await expect(b2.recordBatch(pvBadRoot, PROOF)).to.be.revertedWithCustomError(
|
|
b2, "Discontinuous"
|
|
);
|
|
|
|
// Contiguous + matching prevStateRoot is accepted.
|
|
const gd = await freshTip();
|
|
const pvGood = encodeBatchV2({
|
|
chainId, firstBlock: la + 1, lastBlock: gd.l, numBlocks: gd.l - (la + 1) + 1,
|
|
firstParentHash: ZERO32, lastBlockHash: gd.h,
|
|
prevStateRoot: R1, postStateRoot: R2, totalGas: 42000, numTxns: 2,
|
|
});
|
|
await mock.setValid(marker(VKEY, pvGood, PROOF), true);
|
|
await b2.recordBatch(pvGood, PROOF);
|
|
expect(await b2.provenCount()).to.equal(2n);
|
|
});
|
|
});
|
|
});
|