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.
289 lines
14 KiB
JavaScript
289 lines
14 KiB
JavaScript
const { expect } = require("chai");
|
|
const { ethers, network } = require("hardhat");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AereRecoveryRegistry test.
|
|
//
|
|
// Hardhat has no Falcon-512 precompile, so we install the repo's MockPQCPrecompile
|
|
// (scheme 1) at 0x0AE1 via hardhat_setCode. The mock parses the input at the LIVE
|
|
// precompile's spec offsets and accepts iff commitment == keccak256(pk || message).
|
|
// So a positive record here proves the contract built the 0x0AE1 input at the correct
|
|
// offsets and enforces fail-closed / append-only semantics.
|
|
//
|
|
// [MEASURE] The REAL precompile path (that the on-chain 0x0AE1 accepts a genuine
|
|
// Falcon-512 signature) is exercised separately against LIVE mainnet 2800 via eth_call
|
|
// (scripts/pqc/verify-live-precompile.js) and is NOT re-proven inside this unit test.
|
|
// The contract stays hard-wired to 0x0AE1 for production; the test only swaps in mock
|
|
// bytecode at that address.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FALCON512 = ethers.getAddress("0x0000000000000000000000000000000000000ae1");
|
|
const PK_LEN = 897;
|
|
const PK_HEADER = 0x09;
|
|
const ESIG_HEADER = 0x29;
|
|
|
|
const FAULT_CRASH = 1;
|
|
const FAULT_NETSPLIT = 2;
|
|
const FAULT_BYZANTINE = 3;
|
|
|
|
// Deterministic seeded RNG (keccak hash-chain) so the test is fully reproducible.
|
|
function makeRng(seedText) {
|
|
let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText));
|
|
function bytes(n) {
|
|
const out = new Uint8Array(n);
|
|
let i = 0;
|
|
while (i < n) {
|
|
seed = ethers.keccak256(seed);
|
|
const chunk = ethers.getBytes(seed);
|
|
for (let j = 0; j < chunk.length && i < n; j++, i++) out[i] = chunk[j];
|
|
}
|
|
return out;
|
|
}
|
|
return { bytes };
|
|
}
|
|
|
|
function makePubKey(rng) {
|
|
const raw = rng.bytes(PK_LEN);
|
|
raw[0] = PK_HEADER; // Falcon-512 requires the header byte
|
|
return ethers.hexlify(raw);
|
|
}
|
|
|
|
// commitment the mock expects for a genuine signature over `message` by `pubKey`.
|
|
function commitmentFor(pubKey, message) {
|
|
return ethers.keccak256(ethers.concat([pubKey, message]));
|
|
}
|
|
|
|
// A genuine Falcon-512 signature envelope binding to `message` under `pubKey`:
|
|
// envelope = nonce(40) || esig ; esig = 0x29 || commitment(32) => 73 bytes.
|
|
function genuine(pubKey, message, rng) {
|
|
const nonce = rng.bytes(40);
|
|
const esig = ethers.concat([new Uint8Array([ESIG_HEADER]), commitmentFor(pubKey, message)]);
|
|
return ethers.hexlify(ethers.concat([nonce, esig]));
|
|
}
|
|
|
|
// Flip one byte inside the commitment region (esig[1] at offset 41). This is the
|
|
// region the precompile binds the (key, message) pair to, so a flip is a genuine forgery.
|
|
function tamperCommitment(sigHex) {
|
|
const b = ethers.getBytes(sigHex);
|
|
b[41] ^= 0x01;
|
|
return ethers.hexlify(b);
|
|
}
|
|
|
|
// N=7 fault-boundary context used across the suite (matches the security spec: quorum 5, f=2).
|
|
const N7 = { n: 7, q: 5, aliveKill2: 5, aliveKill3: 4 };
|
|
|
|
describe("AereRecoveryRegistry", function () {
|
|
let reg, mockCode;
|
|
|
|
before(async function () {
|
|
const F = await ethers.getContractFactory("MockPQCPrecompile");
|
|
const mock = await F.deploy(1); // scheme 1 = Falcon-512
|
|
await mock.waitForDeployment();
|
|
mockCode = await ethers.provider.getCode(await mock.getAddress());
|
|
});
|
|
|
|
beforeEach(async function () {
|
|
await network.provider.send("hardhat_setCode", [FALCON512, mockCode]);
|
|
const R = await ethers.getContractFactory("AereRecoveryRegistry");
|
|
reg = await R.deploy();
|
|
await reg.waitForDeployment();
|
|
// Advance block height so historical halt/resume heights are <= block.number.
|
|
await network.provider.send("hardhat_mine", ["0x400"]); // +1024 blocks
|
|
});
|
|
|
|
async function registerOp(pubKey) {
|
|
const tx = await reg.registerOperator(pubKey);
|
|
const rc = await tx.wait();
|
|
const log = rc.logs.map((l) => reg.interface.parseLog(l)).find((p) => p && p.name === "OperatorRegistered");
|
|
return Number(log.args.operatorId);
|
|
}
|
|
|
|
// Build a signed submitRecovery call for a genuine record under `operatorId`.
|
|
async function signedArgs(operatorId, pubKey, rng, f) {
|
|
const nonce = await reg.nonceOf(operatorId);
|
|
const recordHash = await reg.recordChallenge(
|
|
operatorId, nonce, f.halt, f.resume, f.kind, f.n, f.q, f.alive
|
|
);
|
|
const sig = genuine(pubKey, recordHash, rng);
|
|
return [operatorId, f.halt, f.resume, f.kind, f.n, f.q, f.alive, sig];
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Operator registration
|
|
// -----------------------------------------------------------------------
|
|
describe("registerOperator", function () {
|
|
it("accepts a well-formed Falcon-512 key and assigns sequential ids", async function () {
|
|
const rng = makeRng("reg");
|
|
for (let i = 0; i < 3; i++) {
|
|
const pk = makePubKey(rng);
|
|
const id = await registerOp(pk);
|
|
expect(id).to.equal(i);
|
|
const op = await reg.getOperator(id);
|
|
expect(op.pubKey).to.equal(pk);
|
|
expect(Number(op.nonce)).to.equal(0);
|
|
}
|
|
expect(await reg.operatorCount()).to.equal(3n);
|
|
});
|
|
|
|
it("rejects a wrong-length or wrong-header key", async function () {
|
|
await expect(reg.registerOperator("0x" + "09" + "00".repeat(895))).to.be.revertedWithCustomError(reg, "InvalidPubKey"); // 896 bytes
|
|
await expect(reg.registerOperator("0x" + "0a" + "00".repeat(896))).to.be.revertedWithCustomError(reg, "InvalidPubKey"); // wrong header
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Happy path: a valid attestation records
|
|
// -----------------------------------------------------------------------
|
|
describe("submitRecovery (valid attestation records)", function () {
|
|
it("records a genuine kill-2 (f=2 boundary) crash recovery and stores every field", async function () {
|
|
const rng = makeRng("happy");
|
|
const pk = makePubKey(rng);
|
|
const operatorId = await registerOp(pk);
|
|
const f = { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: N7.n, q: N7.q, alive: N7.aliveKill2 };
|
|
|
|
const before = await reg.recordCount();
|
|
const args = await signedArgs(operatorId, pk, rng, f);
|
|
await expect(reg.submitRecovery(...args)).to.emit(reg, "RecoveryAttested");
|
|
|
|
expect(await reg.recordCount()).to.equal(before + 1n);
|
|
expect(Number(await reg.nonceOf(operatorId))).to.equal(1);
|
|
|
|
const r = await reg.getRecord(0);
|
|
expect(Number(r.haltHeight)).to.equal(1000);
|
|
expect(Number(r.resumeHeight)).to.equal(1013);
|
|
expect(Number(r.faultKind)).to.equal(FAULT_CRASH);
|
|
expect(Number(r.nValidators)).to.equal(7);
|
|
expect(Number(r.quorum)).to.equal(5);
|
|
expect(Number(r.aliveDuringFault)).to.equal(5);
|
|
expect(Number(r.operatorId)).to.equal(operatorId);
|
|
expect(Number(r.nonce)).to.equal(0);
|
|
});
|
|
|
|
it("records a netsplit and a byzantine-proposer recovery under the same operator (nonce advances)", async function () {
|
|
const rng = makeRng("kinds");
|
|
const pk = makePubKey(rng);
|
|
const operatorId = await registerOp(pk);
|
|
|
|
await reg.submitRecovery(
|
|
...(await signedArgs(operatorId, pk, rng, { halt: 500, resume: 540, kind: FAULT_NETSPLIT, n: 7, q: 5, alive: 4 }))
|
|
);
|
|
await reg.submitRecovery(
|
|
...(await signedArgs(operatorId, pk, rng, { halt: 600, resume: 612, kind: FAULT_BYZANTINE, n: 7, q: 5, alive: 5 }))
|
|
);
|
|
|
|
expect(await reg.recordCount()).to.equal(2n);
|
|
expect(Number(await reg.nonceOf(operatorId))).to.equal(2);
|
|
expect(Number((await reg.getRecord(0)).faultKind)).to.equal(FAULT_NETSPLIT);
|
|
expect(Number((await reg.getRecord(1)).faultKind)).to.equal(FAULT_BYZANTINE);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Fail-closed: a tampered attestation is rejected
|
|
// -----------------------------------------------------------------------
|
|
describe("fail-closed rejection (nothing recorded)", function () {
|
|
it("rejects a tampered signature and records nothing", async function () {
|
|
const rng = makeRng("tamper");
|
|
const pk = makePubKey(rng);
|
|
const operatorId = await registerOp(pk);
|
|
const f = { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: 7, q: 5, alive: 5 };
|
|
const args = await signedArgs(operatorId, pk, rng, f);
|
|
args[7] = tamperCommitment(args[7]); // corrupt the signature
|
|
|
|
await expect(reg.submitRecovery(...args)).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
|
|
expect(await reg.recordCount()).to.equal(0n);
|
|
expect(Number(await reg.nonceOf(operatorId))).to.equal(0);
|
|
});
|
|
|
|
it("rejects a signature bound to DIFFERENT record fields (heights / fault kind)", async function () {
|
|
const rng = makeRng("bind");
|
|
const pk = makePubKey(rng);
|
|
const operatorId = await registerOp(pk);
|
|
// Sign a record for kind=CRASH at 1000..1013, then try to submit BYZANTINE with the same sig.
|
|
const signed = await signedArgs(operatorId, pk, rng, { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: 7, q: 5, alive: 5 });
|
|
const sig = signed[7];
|
|
await expect(
|
|
reg.submitRecovery(operatorId, 1000, 1013, FAULT_BYZANTINE, 7, 5, 5, sig)
|
|
).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
|
|
// ... and a shifted resume height, same sig.
|
|
await expect(
|
|
reg.submitRecovery(operatorId, 1000, 1099, FAULT_CRASH, 7, 5, 5, sig)
|
|
).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
|
|
expect(await reg.recordCount()).to.equal(0n);
|
|
});
|
|
|
|
it("treats an empty precompile return (pre-fork / stale chain) as invalid", async function () {
|
|
const rng = makeRng("stale");
|
|
const pk = makePubKey(rng);
|
|
const operatorId = await registerOp(pk);
|
|
const args = await signedArgs(operatorId, pk, rng, { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: 7, q: 5, alive: 5 });
|
|
// Wipe the precompile: an empty account returns success + empty returndata.
|
|
await network.provider.send("hardhat_setCode", [FALCON512, "0x"]);
|
|
await expect(reg.submitRecovery(...args)).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
|
|
expect(await reg.recordCount()).to.equal(0n);
|
|
});
|
|
|
|
it("reverts on unknown operator, invalid fault kind, bad heights, and bad validator set", async function () {
|
|
const rng = makeRng("validate");
|
|
const pk = makePubKey(rng);
|
|
const operatorId = await registerOp(pk);
|
|
const dummySig = "0x" + "00".repeat(73);
|
|
const cur = await ethers.provider.getBlockNumber();
|
|
|
|
await expect(reg.submitRecovery(99, 1000, 1013, FAULT_CRASH, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "UnknownOperator");
|
|
await expect(reg.submitRecovery(operatorId, 1000, 1013, 0, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidFaultKind");
|
|
await expect(reg.submitRecovery(operatorId, 1000, 1013, 4, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidFaultKind");
|
|
await expect(reg.submitRecovery(operatorId, 0, 10, FAULT_CRASH, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidHeights");
|
|
await expect(reg.submitRecovery(operatorId, 1013, 1000, FAULT_CRASH, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidHeights"); // resume < halt
|
|
await expect(reg.submitRecovery(operatorId, 1000, cur + 1000, FAULT_CRASH, 7, 5, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidHeights"); // future resume
|
|
await expect(reg.submitRecovery(operatorId, 1000, 1013, FAULT_CRASH, 5, 7, 5, dummySig)).to.be.revertedWithCustomError(reg, "InvalidValidatorSet"); // quorum > n
|
|
await expect(reg.submitRecovery(operatorId, 1000, 1013, FAULT_CRASH, 7, 5, 9, dummySig)).to.be.revertedWithCustomError(reg, "InvalidValidatorSet"); // alive > n
|
|
expect(await reg.recordCount()).to.equal(0n);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Append-only: cannot overwrite
|
|
// -----------------------------------------------------------------------
|
|
describe("append-only (cannot overwrite)", function () {
|
|
it("appends new records without ever rewriting an earlier record, and rejects replay", async function () {
|
|
const rng = makeRng("append");
|
|
const pk = makePubKey(rng);
|
|
const operatorId = await registerOp(pk);
|
|
|
|
// Record 0.
|
|
const args0 = await signedArgs(operatorId, pk, rng, { halt: 1000, resume: 1013, kind: FAULT_CRASH, n: 7, q: 5, alive: 5 });
|
|
await reg.submitRecovery(...args0);
|
|
const r0before = await reg.getRecord(0);
|
|
|
|
// Record 1 (nonce has advanced to 1).
|
|
const args1 = await signedArgs(operatorId, pk, rng, { halt: 2000, resume: 2050, kind: FAULT_NETSPLIT, n: 7, q: 5, alive: 4 });
|
|
await reg.submitRecovery(...args1);
|
|
|
|
expect(await reg.recordCount()).to.equal(2n);
|
|
// Record 0 is byte-for-byte unchanged after record 1 was appended.
|
|
const r0after = await reg.getRecord(0);
|
|
expect(r0after.recordHash).to.equal(r0before.recordHash);
|
|
expect(Number(r0after.haltHeight)).to.equal(1000);
|
|
expect(Number(r0after.resumeHeight)).to.equal(1013);
|
|
expect(Number(r0after.faultKind)).to.equal(FAULT_CRASH);
|
|
// Record 1 landed at a fresh id.
|
|
expect(Number((await reg.getRecord(1)).haltHeight)).to.equal(2000);
|
|
|
|
// Replay of the exact consumed (record 0) signature now fails: the challenge uses nonce 2.
|
|
await expect(reg.submitRecovery(...args0)).to.be.revertedWithCustomError(reg, "PQCVerificationFailed");
|
|
expect(await reg.recordCount()).to.equal(2n);
|
|
// Record 0 still unchanged after the replay attempt.
|
|
expect((await reg.getRecord(0)).recordHash).to.equal(r0before.recordHash);
|
|
});
|
|
|
|
it("exposes no function that can overwrite or delete a record", async function () {
|
|
// Structural check: the ABI has no setter/updater/delete entry point for records.
|
|
const names = reg.interface.fragments.filter((f) => f.type === "function").map((f) => f.name.toLowerCase());
|
|
for (const forbidden of ["set", "update", "edit", "delete", "remove", "overwrite", "rewrite", "setrecord", "admin", "owner"]) {
|
|
expect(names.some((n) => n.includes(forbidden))).to.equal(false);
|
|
}
|
|
});
|
|
});
|
|
});
|