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.
282 lines
11 KiB
JavaScript
282 lines
11 KiB
JavaScript
// FINDING 4 (low): AereHistoryStateRootAnchor.proveAgainstAnchor ran the full
|
|
// SP1 Groth16 verification TWICE on identical inputs (once via VERIFIER.verify
|
|
// to read the block number, then again inside submitProofWithExpectedRoot).
|
|
//
|
|
// This suite:
|
|
// 1. Reproduces the redundant verification against the ORIGINAL V1 contract
|
|
// using a verifier whose verify() reverts. V1's proveAgainstAnchor still
|
|
// calls verify(), so the call reverts, witnessing the redundant path.
|
|
// 2. Proves the corrected V2 contract derives the block number locally and
|
|
// calls ONLY submitProofWithExpectedRoot, so exactly ONE verification runs
|
|
// (submitCalls == 1) while behaviour is unchanged: a valid proof records and
|
|
// tampered / invalid inputs revert.
|
|
//
|
|
// The anchored root is REAL: the AERE genesis header (chain 2800) is
|
|
// reconstructed and its real state root is anchored, then proven against.
|
|
|
|
const { expect } = require("chai");
|
|
const { ethers } = require("hardhat");
|
|
const { encodeHeader } = require("./header-rlp");
|
|
const genesis = require("./fixtures/aere-genesis-header.json");
|
|
|
|
const ZERO32 = "0x" + "00".repeat(32);
|
|
const BLOCK = 5000n; // any block number; the mock injects its canonical hash
|
|
const BLOCK2 = 6000n; // a SECOND anchored block with a DIFFERENT state root
|
|
|
|
// abi.encode(chainId, blockNumber, stateRoot, account, slot, value) -> 192 bytes
|
|
function publicValues({ chainId = 2800n, blockNumber, stateRoot, account, slot, value }) {
|
|
return ethers.AbiCoder.defaultAbiCoder().encode(
|
|
["uint256", "uint256", "bytes32", "address", "bytes32", "bytes32"],
|
|
[chainId, blockNumber, stateRoot, account, slot, value]
|
|
);
|
|
}
|
|
|
|
describe("AereHistoryStateRootAnchor FINDING 4 (double SP1 verify)", function () {
|
|
const account = ethers.getAddress("0x00000000000000000000000000000000000000a1");
|
|
const slot = "0x" + "00".repeat(31) + "05";
|
|
const value = "0x" + "00".repeat(31) + "2a";
|
|
|
|
let verifier;
|
|
|
|
async function deployVerifier() {
|
|
const V = await ethers.getContractFactory("CountingStorageProofVerifier");
|
|
const v = await V.deploy();
|
|
await v.waitForDeployment();
|
|
return v;
|
|
}
|
|
|
|
async function deployAnchor(factoryName, verifierAddr) {
|
|
const F = await ethers.getContractFactory(factoryName);
|
|
const a = await F.deploy(verifierAddr);
|
|
await a.waitForDeployment();
|
|
return a;
|
|
}
|
|
|
|
// Anchor the REAL genesis state root at BLOCK on the given anchor.
|
|
async function anchorGenesis(anchor) {
|
|
const header = encodeHeader(genesis);
|
|
await anchor.setHistoryBlockHash(BLOCK, genesis.hash);
|
|
await anchor.anchorHistoricalHeader(BLOCK, header);
|
|
expect(await anchor.anchoredStateRoot(BLOCK)).to.equal(genesis.stateRoot);
|
|
}
|
|
|
|
// Anchor a SECOND, distinct canonical block at BLOCK2 whose state root differs
|
|
// from genesis. We reuse the real genesis header but overwrite only its
|
|
// stateRoot field, so the header re-encodes to a NEW keccak (a new canonical
|
|
// hash the mock injects) and anchors a different-but-well-formed root. This
|
|
// gives us two anchored blocks with different roots to test cross-block
|
|
// confusion.
|
|
const OTHER_ROOT =
|
|
"0x1111111111111111111111111111111111111111111111111111111111111111";
|
|
async function anchorSecondBlock(anchor) {
|
|
const header2 = encodeHeader({ ...genesis, stateRoot: OTHER_ROOT });
|
|
const hash2 = ethers.keccak256(header2);
|
|
await anchor.setHistoryBlockHash(BLOCK2, hash2);
|
|
await anchor.anchorHistoricalHeader(BLOCK2, header2);
|
|
expect(await anchor.anchoredStateRoot(BLOCK2)).to.equal(OTHER_ROOT);
|
|
}
|
|
|
|
beforeEach(async function () {
|
|
verifier = await deployVerifier();
|
|
});
|
|
|
|
describe("reproduction: V1 makes the redundant verify() call", function () {
|
|
it("proveAgainstAnchor reverts because V1 still calls verify() (double verification)", async function () {
|
|
const anchor = await deployAnchor("MockHistoryAnchorV1", await verifier.getAddress());
|
|
await anchorGenesis(anchor);
|
|
|
|
const pv = publicValues({
|
|
blockNumber: BLOCK,
|
|
stateRoot: genesis.stateRoot,
|
|
account,
|
|
slot,
|
|
value,
|
|
});
|
|
|
|
// The counting verifier rejects any verify() call. V1 calls verify()
|
|
// first, so the whole tx reverts: this is the redundant verification the
|
|
// finding describes. On a real verifier the call would instead succeed and
|
|
// burn a full extra Groth16 pairing.
|
|
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWithCustomError(
|
|
verifier,
|
|
"RedundantVerifyCall"
|
|
);
|
|
|
|
// No verification ever reached the recording path.
|
|
expect(await verifier.submitCalls()).to.equal(0n);
|
|
});
|
|
});
|
|
|
|
describe("fix: V2 verifies exactly once", function () {
|
|
let anchor;
|
|
|
|
beforeEach(async function () {
|
|
anchor = await deployAnchor("MockHistoryAnchorV2", await verifier.getAddress());
|
|
await anchorGenesis(anchor);
|
|
});
|
|
|
|
it("records a valid proof running submitProofWithExpectedRoot ONCE and never verify()", async function () {
|
|
const pv = publicValues({
|
|
blockNumber: BLOCK,
|
|
stateRoot: genesis.stateRoot,
|
|
account,
|
|
slot,
|
|
value,
|
|
});
|
|
|
|
await expect(anchor.proveAgainstAnchor(pv, "0x"))
|
|
.to.emit(anchor, "ProvenAgainstAnchor")
|
|
.withArgs(BLOCK, genesis.stateRoot, account, slot, value);
|
|
|
|
// Exactly one verification, via the recording path only.
|
|
expect(await verifier.submitCalls()).to.equal(1n);
|
|
expect(await verifier.recorded(ethers.keccak256(pv))).to.equal(true);
|
|
});
|
|
|
|
it("reverts when the committed root is tampered to differ from the anchored root", async function () {
|
|
const pv = publicValues({
|
|
blockNumber: BLOCK,
|
|
stateRoot: ZERO32, // not the anchored genesis root
|
|
account,
|
|
slot,
|
|
value,
|
|
});
|
|
|
|
// submit binds committed root to anchored root; mismatch reverts.
|
|
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWith("StateRootMismatch");
|
|
expect(await verifier.submitCalls()).to.equal(0n);
|
|
});
|
|
|
|
it("reverts NotAnchored when the proof's block number was never anchored", async function () {
|
|
const pv = publicValues({
|
|
blockNumber: 999999n,
|
|
stateRoot: genesis.stateRoot,
|
|
account,
|
|
slot,
|
|
value,
|
|
});
|
|
|
|
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWithCustomError(
|
|
anchor,
|
|
"NotAnchored"
|
|
);
|
|
// Reverts before any verification.
|
|
expect(await verifier.submitCalls()).to.equal(0n);
|
|
});
|
|
|
|
it("reverts BadPublicValuesLength for a non-192-byte payload", async function () {
|
|
const short = "0x" + "11".repeat(160); // 160 bytes, one word short
|
|
await expect(anchor.proveAgainstAnchor(short, "0x")).to.be.revertedWithCustomError(
|
|
anchor,
|
|
"BadPublicValuesLength"
|
|
);
|
|
});
|
|
|
|
it("reverts when the underlying SP1 proof is invalid (verification still gates recording)", async function () {
|
|
await verifier.setValidate(false);
|
|
const pv = publicValues({
|
|
blockNumber: BLOCK,
|
|
stateRoot: genesis.stateRoot,
|
|
account,
|
|
slot,
|
|
value,
|
|
});
|
|
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.reverted;
|
|
expect(await verifier.submitCalls()).to.equal(0n);
|
|
});
|
|
|
|
it("decodes block number, account, and slot locally from the 192-byte layout", async function () {
|
|
const account2 = ethers.getAddress("0x00000000000000000000000000000000000000b2");
|
|
const slot2 = "0x" + "00".repeat(31) + "09";
|
|
const value2 = "0x" + "00".repeat(30) + "0100";
|
|
const pv = publicValues({
|
|
blockNumber: BLOCK,
|
|
stateRoot: genesis.stateRoot,
|
|
account: account2,
|
|
slot: slot2,
|
|
value: value2,
|
|
});
|
|
await expect(anchor.proveAgainstAnchor(pv, "0x"))
|
|
.to.emit(anchor, "ProvenAgainstAnchor")
|
|
.withArgs(BLOCK, genesis.stateRoot, account2, slot2, value2);
|
|
expect(await verifier.submitCalls()).to.equal(1n);
|
|
});
|
|
});
|
|
|
|
// RESIDUAL ATTACK VARIANT (cross-block root confusion).
|
|
//
|
|
// The V2 fix selects the anchored root using the blockNumber it decodes
|
|
// LOCALLY from public-values word 1, and no longer cross-checks any block
|
|
// number returned by VERIFIER.verify (that call is gone). So the sharpest
|
|
// residual question is: can an attacker take a proof that is genuinely valid
|
|
// for anchored block A and redirect it against a DIFFERENT anchored block B
|
|
// (word 1 = B) to get an off-root value recorded? It must NOT: the single
|
|
// submitProofWithExpectedRoot binds the proof's committed root (word 2) to
|
|
// anchored[B]'s root, so the tampered block number reverts, and the exactly-
|
|
// once property must hold (submitCalls stays 0 on the failed attack, and a
|
|
// later honest proof still records exactly once with no verify() call).
|
|
describe("residual attack variant: cross-block root confusion", function () {
|
|
it("V1 (reproduction) still runs the redundant verify() on the cross-block attempt", async function () {
|
|
const anchor = await deployAnchor("MockHistoryAnchorV1", await verifier.getAddress());
|
|
await anchorGenesis(anchor);
|
|
await anchorSecondBlock(anchor);
|
|
|
|
// Proof genuinely valid for BLOCK (root = genesis root) but word 1 tampered
|
|
// to BLOCK2 (anchored to OTHER_ROOT).
|
|
const pv = publicValues({
|
|
blockNumber: BLOCK2,
|
|
stateRoot: genesis.stateRoot,
|
|
account,
|
|
slot,
|
|
value,
|
|
});
|
|
|
|
// V1 calls verify() FIRST, so it reverts on the redundant path rather than
|
|
// through the single submit binding. This witnesses the double-verify the
|
|
// finding removes.
|
|
await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWithCustomError(
|
|
verifier,
|
|
"RedundantVerifyCall"
|
|
);
|
|
expect(await verifier.submitCalls()).to.equal(0n);
|
|
});
|
|
|
|
it("V2 (closed) rejects the cross-block attempt through the single submit binding, no verify(), no record", async function () {
|
|
const anchor = await deployAnchor("MockHistoryAnchorV2", await verifier.getAddress());
|
|
await anchorGenesis(anchor);
|
|
await anchorSecondBlock(anchor);
|
|
|
|
const attackPv = publicValues({
|
|
blockNumber: BLOCK2, // redirect a genesis-root proof at the OTHER_ROOT block
|
|
stateRoot: genesis.stateRoot,
|
|
account,
|
|
slot,
|
|
value,
|
|
});
|
|
|
|
// Anchored[BLOCK2] = OTHER_ROOT, committed root = genesis root: submit binds
|
|
// them and reverts. No fallback to verify(), and nothing recorded.
|
|
await expect(anchor.proveAgainstAnchor(attackPv, "0x")).to.be.revertedWith(
|
|
"StateRootMismatch"
|
|
);
|
|
expect(await verifier.submitCalls()).to.equal(0n);
|
|
expect(await verifier.recorded(ethers.keccak256(attackPv))).to.equal(false);
|
|
|
|
// The failed attack corrupts nothing: an honest proof for BLOCK2's real
|
|
// root still records, and does so with EXACTLY ONE verification.
|
|
const honestPv = publicValues({
|
|
blockNumber: BLOCK2,
|
|
stateRoot: OTHER_ROOT,
|
|
account,
|
|
slot,
|
|
value,
|
|
});
|
|
await expect(anchor.proveAgainstAnchor(honestPv, "0x"))
|
|
.to.emit(anchor, "ProvenAgainstAnchor")
|
|
.withArgs(BLOCK2, OTHER_ROOT, account, slot, value);
|
|
expect(await verifier.submitCalls()).to.equal(1n);
|
|
expect(await verifier.recorded(ethers.keccak256(honestPv))).to.equal(true);
|
|
});
|
|
});
|
|
});
|