aere-contracts/test/AereThresholdPQCRegistry.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

436 lines
21 KiB
JavaScript

const { expect } = require("chai");
const { ethers, network } = require("hardhat");
// ---------------------------------------------------------------------------
// AereThresholdPQCRegistry -- on-chain t-of-n POST-QUANTUM threshold authorization.
//
// A committee of n PQC signers each hold their own NIST PQC key. A valid authorization
// requires >= t DISTINCT members to each PQC-sign the SAME domain-separated challenge,
// with every leg verified on-chain by the scheme's live precompile (0x0AE1..0x0AE4).
//
// HONEST label: this is a threshold MULTISIG of independent PQC signatures, NOT a single
// aggregate threshold-PQC signature (that is open research; see docs/THRESHOLD-PQC-*.md).
//
// Precompile modelling: Hardhat has no PQC precompile, so we install the repo's faithful
// MockPQCPrecompile at 0x0AE1..0x0AE4 (hardhat_setCode). It parses the input at the
// precompiles' SPEC offsets and accepts iff commitment == keccak256(pk || message) -- the
// same model the shipped AerePQCAttestation adversarial suite uses. Because the mock parses
// at the spec offsets (not offsets copied from the contract), a passing authorize() proves
// BOTH the threshold-counting logic AND that the contract built the precompile envelope at
// the correct offsets. The REAL envelope is separately proven bit-for-bit against the LIVE
// mainnet precompile via eth_call (scripts/pqc/verify-live-precompile.js).
// ---------------------------------------------------------------------------
const PRECOMPILE = {
1: ethers.getAddress("0x0000000000000000000000000000000000000ae1"), // Falcon-512
2: ethers.getAddress("0x0000000000000000000000000000000000000ae2"), // Falcon-1024
3: ethers.getAddress("0x0000000000000000000000000000000000000ae3"), // ML-DSA-44
4: ethers.getAddress("0x0000000000000000000000000000000000000ae4"), // SLH-DSA-128s
};
const META = {
1: { pkLen: 897, pkHeader: 0x09, esigHeader: 0x29, falcon: true },
2: { pkLen: 1793, pkHeader: 0x0a, esigHeader: 0x2a, falcon: true },
3: { pkLen: 1312, sigLen: 2420, falcon: false },
4: { pkLen: 32, sigLen: 7856, falcon: false },
};
// Deterministic seeded RNG (keccak hash-chain) so the whole suite is 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(scheme, rng) {
const m = META[scheme];
const raw = rng.bytes(m.pkLen);
if (m.falcon) raw[0] = m.pkHeader; // Falcon 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]));
}
// Build a signature envelope carrying a chosen commitment (genuine or forged).
function envelope(scheme, commitment, rng) {
const m = META[scheme];
if (m.falcon) {
const nonce = rng.bytes(40);
const esig = ethers.concat([new Uint8Array([m.esigHeader]), commitment]); // 33 bytes
return ethers.hexlify(ethers.concat([nonce, esig]));
} else {
const sig = new Uint8Array(m.sigLen);
sig.set(ethers.getBytes(commitment), 0);
return ethers.hexlify(sig);
}
}
// A genuine signature by `pubKey` over `message`.
function genuine(scheme, pubKey, message, rng) {
return envelope(scheme, commitmentFor(pubKey, message), rng);
}
// Flip one byte inside the commitment region -> a genuine forgery the precompile rejects.
function tamperCommitment(scheme, sigHex) {
const b = ethers.getBytes(sigHex);
const idx = META[scheme].falcon ? 41 : 0;
b[idx] ^= 0x01;
return ethers.hexlify(b);
}
describe("AereThresholdPQCRegistry (post-quantum threshold multisig)", function () {
let reg, mockCode;
before(async function () {
// Deploy one mock per scheme; capture its runtime bytecode (immutable scheme baked in).
mockCode = {};
const F = await ethers.getContractFactory("MockPQCPrecompile");
for (const scheme of [1, 2, 3, 4]) {
const mock = await F.deploy(scheme);
await mock.waitForDeployment();
mockCode[scheme] = await ethers.provider.getCode(await mock.getAddress());
}
});
async function installMocks() {
for (const scheme of [1, 2, 3, 4]) {
await network.provider.send("hardhat_setCode", [PRECOMPILE[scheme], mockCode[scheme]]);
}
}
beforeEach(async function () {
await installMocks();
const R = await ethers.getContractFactory("AereThresholdPQCRegistry");
reg = await R.deploy();
await reg.waitForDeployment();
});
// Register a fresh n-member committee of `scheme`, threshold t. Returns the committee id,
// its member public keys, and a shared rng.
async function registerCommittee(scheme, n, t, seed) {
const rng = makeRng(seed || `c-${scheme}-${n}-${t}`);
const pubKeys = [];
for (let i = 0; i < n; i++) pubKeys.push(makePubKey(scheme, rng));
const tx = await reg.registerCommittee(scheme, t, pubKeys);
const rc = await tx.wait();
const log = rc.logs.map((l) => reg.interface.parseLog(l)).find((p) => p && p.name === "CommitteeRegistered");
return { id: Number(log.args.committeeId), pubKeys, rng, scheme, n, t };
}
// Build the `sigs` array of {memberIndex, signature} for the given member indices, each a
// genuine PQC signature over the committee's current authorization challenge.
async function legsFor(c, messageHash, memberIndices) {
const nonce = await reg.nonceOf(c.id);
const challenge = await reg.authChallenge(c.id, nonce, messageHash);
return memberIndices.map((idx) => ({
memberIndex: idx,
signature: genuine(c.scheme, c.pubKeys[idx], challenge, c.rng),
}));
}
// -----------------------------------------------------------------------
// Registration validation
// -----------------------------------------------------------------------
describe("registerCommittee", function () {
it("registers a committee for every scheme and stores scheme/t/n + members commitment", async function () {
for (const scheme of [1, 2, 3, 4]) {
const c = await registerCommittee(scheme, 3, 2, `reg-${scheme}`);
const got = await reg.getCommittee(c.id);
expect(got.exists).to.equal(true);
expect(Number(got.scheme)).to.equal(scheme);
expect(Number(got.threshold)).to.equal(2);
expect(Number(got.size)).to.equal(3);
expect(Number(got.authNonce)).to.equal(0);
// members commitment matches abi.encode(pubKeys)
const expectHash = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(["bytes[]"], [c.pubKeys]),
);
expect(got.membersHash).to.equal(expectHash);
// stored keys are retrievable
expect(await reg.getMemberPubKey(c.id, 0)).to.equal(c.pubKeys[0]);
expect(await reg.precompileFor(scheme)).to.equal(PRECOMPILE[scheme]);
}
});
it("rejects bad committee params (t=0, t>n, empty, n>255)", async function () {
const rng = makeRng("bad");
const three = [0, 1, 2].map(() => makePubKey(1, rng));
await expect(reg.registerCommittee(1, 0, three)).to.be.revertedWithCustomError(reg, "BadCommitteeParams");
await expect(reg.registerCommittee(1, 4, three)).to.be.revertedWithCustomError(reg, "BadCommitteeParams");
await expect(reg.registerCommittee(1, 1, [])).to.be.revertedWithCustomError(reg, "BadCommitteeParams");
});
it("rejects an unknown scheme and malformed member keys", async function () {
const rng = makeRng("mal");
const goodF = makePubKey(1, rng);
// unknown scheme
await expect(reg.registerCommittee(5, 1, [goodF])).to.be.revertedWithCustomError(reg, "InvalidScheme");
// Falcon-512 wrong header
const badHeader = ethers.getBytes(makePubKey(1, rng));
badHeader[0] = 0x0a;
await expect(reg.registerCommittee(1, 1, [ethers.hexlify(badHeader)])).to.be.revertedWithCustomError(
reg,
"InvalidPubKey",
);
// ML-DSA-44 wrong length
await expect(reg.registerCommittee(3, 1, ["0x" + "00".repeat(1311)])).to.be.revertedWithCustomError(
reg,
"InvalidPubKey",
);
// the offending member index is reported
await expect(reg.registerCommittee(1, 2, [goodF, "0x" + "09" + "00".repeat(10)]))
.to.be.revertedWithCustomError(reg, "InvalidPubKey")
.withArgs(1);
});
it("rejects a committee with duplicate keys (distinctness is by KEY, not index)", async function () {
// Authorization counts distinct signers by member index, so a repeated key would let one
// keyholder fill multiple slots and reach threshold alone. registerCommittee must reject it.
const rng = makeRng("dup-key-registry");
const keys = [];
for (let i = 0; i < 4; i++) keys.push(makePubKey(1, rng));
keys[2] = keys[0]; // member 2 reuses member 0's key
await expect(reg.registerCommittee(1, 2, keys))
.to.be.revertedWithCustomError(reg, "DuplicatePubKey")
.withArgs(2);
});
});
// -----------------------------------------------------------------------
// t-of-n happy path + threshold gate
// -----------------------------------------------------------------------
describe("authorize: t-of-n happy path and threshold gate", function () {
it("authorizes with EXACTLY t distinct valid PQC legs (2-of-3, Falcon-512)", async function () {
const c = await registerCommittee(1, 3, 2, "happy-f512");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("release vault payment #1"));
const sigs = await legsFor(c, messageHash, [0, 2]); // any 2 distinct members
const before = await reg.authorizationCount();
await expect(reg.authorize(c.id, messageHash, sigs))
.to.emit(reg, "Authorized")
.withArgs(c.id, 0, messageHash, await reg.authChallenge(c.id, 0, messageHash), 2, anyBlock());
expect(await reg.authorizationCount()).to.equal(before + 1n);
expect(Number(await reg.nonceOf(c.id))).to.equal(1);
const a = await reg.getAuthorization(c.id, 0);
expect(a.exists).to.equal(true);
expect(a.messageHash).to.equal(messageHash);
expect(Number(a.quorum)).to.equal(2);
expect(await reg.isAuthorized(c.id, 0, messageHash)).to.equal(true);
expect(await reg.isAuthorized(c.id, 0, ethers.ZeroHash)).to.equal(false);
});
it("authorizes for EVERY scheme with a t-of-n quorum", async function () {
for (const scheme of [1, 2, 3, 4]) {
const c = await registerCommittee(scheme, 4, 3, `all-${scheme}`);
const messageHash = ethers.keccak256(ethers.toUtf8Bytes(`msg-${scheme}`));
const sigs = await legsFor(c, messageHash, [0, 1, 3]);
await expect(reg.authorize(c.id, messageHash, sigs)).to.emit(reg, "Authorized");
expect(Number(await reg.nonceOf(c.id))).to.equal(1);
}
});
it("REJECTS a below-threshold quorum (t-1 valid legs)", async function () {
const c = await registerCommittee(1, 3, 2, "below");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("only one signer"));
const sigs = await legsFor(c, messageHash, [1]); // 1 < t=2
await expect(reg.authorize(c.id, messageHash, sigs))
.to.be.revertedWithCustomError(reg, "BelowThreshold")
.withArgs(1, 2);
// nothing recorded, nonce frozen
expect(await reg.authorizationCount()).to.equal(0n);
expect(Number(await reg.nonceOf(c.id))).to.equal(0);
expect((await reg.getAuthorization(c.id, 0)).exists).to.equal(false);
});
it("previewAuthorize reports quorum without recording (lenient view)", async function () {
const c = await registerCommittee(2, 5, 3, "preview");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("preview-msg"));
const nonce = await reg.nonceOf(c.id);
const three = await legsFor(c, messageHash, [0, 2, 4]);
const two = three.slice(0, 2);
expect((await reg.previewAuthorize(c.id, nonce, messageHash, three)).would).to.equal(true);
expect((await reg.previewAuthorize(c.id, nonce, messageHash, two)).would).to.equal(false);
// view did not mutate state
expect(Number(await reg.nonceOf(c.id))).to.equal(0);
});
});
// -----------------------------------------------------------------------
// Distinctness, membership, message binding, replay
// -----------------------------------------------------------------------
describe("authorize: distinctness / membership / binding / replay", function () {
it("does NOT double-count a duplicate member (reverts DuplicateMember)", async function () {
const c = await registerCommittee(1, 3, 2, "dup");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("dup-msg"));
const nonce = await reg.nonceOf(c.id);
const challenge = await reg.authChallenge(c.id, nonce, messageHash);
// member 0 signs twice: two legs, both valid, but same index -> not a 2-of-3 quorum
const m0a = { memberIndex: 0, signature: genuine(c.scheme, c.pubKeys[0], challenge, c.rng) };
const m0b = { memberIndex: 0, signature: genuine(c.scheme, c.pubKeys[0], challenge, c.rng) };
await expect(reg.authorize(c.id, messageHash, [m0a, m0b]))
.to.be.revertedWithCustomError(reg, "DuplicateMember")
.withArgs(0);
expect(await reg.authorizationCount()).to.equal(0n);
});
it("rejects an out-of-range member index (non-member)", async function () {
const c = await registerCommittee(1, 3, 2, "oor");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("oor-msg"));
const good = await legsFor(c, messageHash, [0]);
const bogus = { memberIndex: 9, signature: good[0].signature };
await expect(reg.authorize(c.id, messageHash, [good[0], bogus]))
.to.be.revertedWithCustomError(reg, "MemberOutOfRange")
.withArgs(9);
});
it("rejects a leg signed by a NON-member key at a valid index", async function () {
const c = await registerCommittee(1, 3, 2, "foreign");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("foreign-msg"));
const nonce = await reg.nonceOf(c.id);
const challenge = await reg.authChallenge(c.id, nonce, messageHash);
const outsiderKey = makePubKey(1, c.rng); // never registered
const legGood = { memberIndex: 0, signature: genuine(c.scheme, c.pubKeys[0], challenge, c.rng) };
const legForeign = { memberIndex: 1, signature: genuine(c.scheme, outsiderKey, challenge, c.rng) };
await expect(reg.authorize(c.id, messageHash, [legGood, legForeign]))
.to.be.revertedWithCustomError(reg, "LegVerificationFailed")
.withArgs(1);
});
it("rejects a leg that is a tampered / invalid PQC signature", async function () {
const c = await registerCommittee(2, 3, 2, "tamper");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("tamper-msg"));
const sigs = await legsFor(c, messageHash, [0, 1]);
sigs[1].signature = tamperCommitment(c.scheme, sigs[1].signature);
await expect(reg.authorize(c.id, messageHash, sigs))
.to.be.revertedWithCustomError(reg, "LegVerificationFailed")
.withArgs(1);
});
it("rejects legs that are genuine over a DIFFERENT message", async function () {
const c = await registerCommittee(1, 3, 2, "wrongmsg");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("intended"));
const otherHash = ethers.keccak256(ethers.toUtf8Bytes("attacker-substituted"));
// sign the challenge for `otherHash`, then submit against `messageHash`
const nonce = await reg.nonceOf(c.id);
const otherChallenge = await reg.authChallenge(c.id, nonce, otherHash);
const sigs = [0, 1].map((idx) => ({
memberIndex: idx,
signature: genuine(c.scheme, c.pubKeys[idx], otherChallenge, c.rng),
}));
await expect(reg.authorize(c.id, messageHash, sigs)).to.be.revertedWithCustomError(reg, "LegVerificationFailed");
expect(await reg.authorizationCount()).to.equal(0n);
});
it("prevents REPLAY of a used authorization (nonce advances, same sigs no longer verify)", async function () {
const c = await registerCommittee(1, 3, 2, "replay");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("pay once"));
const sigs = await legsFor(c, messageHash, [0, 1]); // legs bound to nonce 0
await reg.authorize(c.id, messageHash, sigs); // consumes nonce 0
expect(Number(await reg.nonceOf(c.id))).to.equal(1);
// exact replay: challenge now uses nonce 1, so the nonce-0 legs fail verification
await expect(reg.authorize(c.id, messageHash, sigs)).to.be.revertedWithCustomError(reg, "LegVerificationFailed");
expect(await reg.authorizationCount()).to.equal(1n);
expect(Number(await reg.nonceOf(c.id))).to.equal(1);
});
it("allows a fresh authorization at the new nonce after a successful one", async function () {
const c = await registerCommittee(3, 4, 3, "second");
const msg1 = ethers.keccak256(ethers.toUtf8Bytes("first"));
await reg.authorize(c.id, msg1, await legsFor(c, msg1, [0, 1, 2]));
expect(Number(await reg.nonceOf(c.id))).to.equal(1);
const msg2 = ethers.keccak256(ethers.toUtf8Bytes("second"));
await reg.authorize(c.id, msg2, await legsFor(c, msg2, [1, 2, 3]));
expect(Number(await reg.nonceOf(c.id))).to.equal(2);
expect(await reg.isAuthorized(c.id, 0, msg1)).to.equal(true);
expect(await reg.isAuthorized(c.id, 1, msg2)).to.equal(true);
});
it("reverts on an unknown committee", async function () {
await expect(reg.authorize(999, ethers.ZeroHash, [])).to.be.revertedWithCustomError(reg, "UnknownCommittee");
});
it("treats an empty (pre-fork / wiped) precompile as an invalid leg, not an authorization", async function () {
const c = await registerCommittee(2, 3, 2, "wiped");
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("wiped-msg"));
const sigs = await legsFor(c, messageHash, [0, 1]);
await network.provider.send("hardhat_setCode", [PRECOMPILE[2], "0x"]); // wipe Falcon-1024 precompile
await expect(reg.authorize(c.id, messageHash, sigs)).to.be.revertedWithCustomError(reg, "LegVerificationFailed");
expect(await reg.authorizationCount()).to.equal(0n);
});
});
// -----------------------------------------------------------------------
// Gas ceiling under EIP-7825: measure the marginal cost per additional leg.
// NB: the mock verify is cheaper than the live precompile's FIXED verify gas
// (Falcon-512 40k, Falcon-1024 75k, ML-DSA-44 55k, SLH-DSA-128s 350k). So the
// REAL per-leg cost = (measured marginal here, which is calldata + envelope build +
// distinct-member bookkeeping + cheap mock verify) + the precompile's fixed verify gas.
// This test reports the Solidity/calldata overhead per leg so the ceiling in
// docs/THRESHOLD-PQC-2026-07-12.md is grounded in a measurement, not a guess.
// -----------------------------------------------------------------------
describe("gas: per-leg marginal cost (informs the EIP-7825 (scheme, t) ceiling)", function () {
const CAP = 16_777_216n;
const PRECOMPILE_VERIFY_GAS = { 1: 40000n, 2: 75000n, 3: 55000n, 4: 350000n };
async function authGas(scheme, t, seed) {
const n = t; // exactly-t quorum
const c = await registerCommittee(scheme, n, t, seed);
const messageHash = ethers.keccak256(ethers.toUtf8Bytes(seed));
const sigs = await legsFor(
c,
messageHash,
Array.from({ length: t }, (_, i) => i),
);
const tx = await reg.authorize(c.id, messageHash, sigs);
const rc = await tx.wait();
return rc.gasUsed;
}
it("reports marginal Solidity+calldata overhead per leg and the projected real ceiling", async function () {
for (const scheme of [1, 2, 3, 4]) {
const g2 = await authGas(scheme, 2, `gas-${scheme}-2`);
const g6 = await authGas(scheme, 6, `gas-${scheme}-6`);
const overheadPerLeg = (g6 - g2) / 4n; // 4 extra legs between t=2 and t=6
// Real per-leg = measured Solidity/calldata overhead (which already includes the
// cheap mock verify) + the precompile's fixed verify gas. Slightly conservative
// (double-counts the tiny mock verify, so the ceiling below is an under-estimate).
const realPerLeg = overheadPerLeg + PRECOMPILE_VERIFY_GAS[scheme];
const fixedOverhead = g2 - overheadPerLeg * 2n; // tx base + committee load + record
const tMax = (CAP - fixedOverhead) / realPerLeg;
console.log(
` scheme ${scheme}: mock authorize t=2 ${g2} gas, t=6 ${g6} gas; ` +
`Solidity/calldata overhead/leg ~${overheadPerLeg}; ` +
`+precompile ${PRECOMPILE_VERIFY_GAS[scheme]} => real/leg ~${realPerLeg}; ` +
`projected t_max under 2^24 ~= ${tMax}`,
);
// Sanity: the per-leg cost is positive and the projected ceiling is a real bound.
expect(overheadPerLeg > 0n).to.equal(true);
expect(tMax > 0n).to.equal(true);
// Falcon-512 (cheapest verify) must admit a larger quorum than SLH-DSA-128s.
if (scheme === 1) expect(tMax > 20n).to.equal(true);
}
});
});
});
// chai matcher helper: accept any block number in withArgs.
function anyBlock() {
return (x) => typeof x === "bigint" || typeof x === "number";
}