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

391 lines
18 KiB
JavaScript

const { expect } = require("chai");
const { ethers, network } = require("hardhat");
// ---------------------------------------------------------------------------
// AerePQCAttestation adversarial test + seeded invariant fuzz.
//
// Hardhat has no PQC precompile, so we install MockPQCPrecompile at 0x0AE1..0x0AE4
// (hardhat_setCode). The mock parses the input at the precompiles' SPEC offsets and
// accepts iff commitment == keccak256(pk || message). A "genuine signature" is one
// whose commitment equals keccak256(pk || challenge); a forgery/replay/wrong-key/
// wrong-message signature has a mismatching commitment and MUST be rejected.
//
// The REAL precompile encoding is separately proven 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 fuzz 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;
}
function int(n) {
const b = bytes(4);
return (((b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]) >>> 0) % n;
}
return { bytes, int };
}
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 the attester signature envelope with 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 envelope binding to `message` under `pubKey`.
function genuine(scheme, pubKey, message, rng) {
return envelope(scheme, commitmentFor(pubKey, message), rng);
}
// Flip one byte inside the commitment region (Falcon: esig[1] at offset 41;
// fixed schemes: sig[0] at offset 0). This is the region the precompile binds
// the (key,message) pair to, so a flip here is a genuine forgery.
function tamperCommitment(scheme, sigHex) {
const b = ethers.getBytes(sigHex);
const idx = META[scheme].falcon ? 41 : 0;
b[idx] ^= 0x01;
return ethers.hexlify(b);
}
describe("AerePQCAttestation (adversarial + seeded fuzz)", function () {
let att, 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 A = await ethers.getContractFactory("AerePQCAttestation");
att = await A.deploy();
await att.waitForDeployment();
});
// Helper: register a key, return its keyId (from the event).
async function register(scheme, pubKey) {
const tx = await att.registerKey(scheme, pubKey);
const rc = await tx.wait();
const log = rc.logs.map((l) => att.interface.parseLog(l)).find((p) => p && p.name === "KeyRegistered");
return Number(log.args.keyId);
}
// -----------------------------------------------------------------------
// Registration validation
// -----------------------------------------------------------------------
describe("registerKey", function () {
it("accepts a well-formed key for every scheme and assigns sequential ids", async function () {
const rng = makeRng("reg");
let expectedId = 0;
for (const scheme of [1, 2, 3, 4]) {
const pk = makePubKey(scheme, rng);
const id = await register(scheme, pk);
expect(id).to.equal(expectedId++);
const k = await att.getKey(id);
expect(Number(k.scheme)).to.equal(scheme);
expect(k.pubKey).to.equal(pk);
expect(Number(k.nonce)).to.equal(0);
}
expect(await att.keyCount()).to.equal(4n);
});
it("rejects an unknown scheme", async function () {
await expect(att.registerKey(5, "0x" + "00".repeat(897))).to.be.revertedWithCustomError(att, "InvalidScheme");
await expect(att.registerKey(0, "0x" + "00".repeat(897))).to.be.revertedWithCustomError(att, "InvalidScheme");
});
it("rejects a wrong-length or wrong-header public key", async function () {
// Falcon-1024 wrong length
await expect(att.registerKey(2, "0x" + "0a" + "00".repeat(1791))).to.be.revertedWithCustomError(att, "InvalidPubKey");
// Falcon-1024 correct length but wrong header (0x09 instead of 0x0A)
await expect(att.registerKey(2, "0x" + "09" + "00".repeat(1792))).to.be.revertedWithCustomError(att, "InvalidPubKey");
// Falcon-512 correct length but wrong header
await expect(att.registerKey(1, "0x" + "0a" + "00".repeat(896))).to.be.revertedWithCustomError(att, "InvalidPubKey");
// ML-DSA-44 wrong length
await expect(att.registerKey(3, "0x" + "00".repeat(1311))).to.be.revertedWithCustomError(att, "InvalidPubKey");
// SLH-DSA wrong length
await expect(att.registerKey(4, "0x" + "00".repeat(31))).to.be.revertedWithCustomError(att, "InvalidPubKey");
});
});
// -----------------------------------------------------------------------
// Happy path per scheme
// -----------------------------------------------------------------------
describe("attest happy path", function () {
for (const scheme of [1, 2, 3, 4]) {
it(`records a genuine attestation for scheme ${scheme}`, async function () {
const rng = makeRng("happy-" + scheme);
const pk = makePubKey(scheme, rng);
const keyId = await register(scheme, pk);
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("payload-" + scheme));
const challenge = await att.attestChallenge(keyId, 0, messageHash);
const sig = genuine(scheme, pk, challenge, rng);
const before = await att.attestationCount();
await expect(att.attest(keyId, messageHash, sig)).to.emit(att, "Attested");
expect(await att.attestationCount()).to.equal(before + 1n);
expect(Number(await att.nonceOf(keyId))).to.equal(1);
const a = await att.getAttestation(keyId, 0);
expect(a.exists).to.equal(true);
expect(a.messageHash).to.equal(messageHash);
expect(a.challenge).to.equal(challenge);
expect(await att.isValidAttestation(keyId, 0, messageHash)).to.equal(true);
expect(await att.isValidAttestation(keyId, 0, ethers.ZeroHash)).to.equal(false);
});
}
it("verifySignature view returns true for genuine, false for tampered (raw messageHash binding)", async function () {
const rng = makeRng("view");
const scheme = 2;
const pk = makePubKey(scheme, rng);
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("direct-verify"));
const good = genuine(scheme, pk, messageHash, rng);
expect(await att.verifySignature(scheme, pk, messageHash, good)).to.equal(true);
const bad = ethers.getBytes(good);
bad[bad.length - 1] ^= 0xff;
expect(await att.verifySignature(scheme, pk, messageHash, ethers.hexlify(bad))).to.equal(false);
});
});
// -----------------------------------------------------------------------
// Adversarial: forgery, wrong key, wrong message, replay
// -----------------------------------------------------------------------
describe("adversarial rejection (nothing recorded)", function () {
async function setup(scheme) {
const rng = makeRng("adv-" + scheme);
const pk = makePubKey(scheme, rng);
const keyId = await register(scheme, pk);
const messageHash = ethers.keccak256(ethers.toUtf8Bytes("adv-msg"));
const challenge = await att.attestChallenge(keyId, 0, messageHash);
return { rng, pk, keyId, messageHash, challenge };
}
async function expectRejected(promise) {
await expect(promise).to.be.revertedWithCustomError(att, "PQCVerificationFailed");
}
for (const scheme of [1, 2, 3, 4]) {
it(`rejects a tampered signature for scheme ${scheme}`, async function () {
const { rng, pk, keyId, messageHash, challenge } = await setup(scheme);
const good = tamperCommitment(scheme, genuine(scheme, pk, challenge, rng));
await expectRejected(att.attest(keyId, messageHash, good));
expect(await att.attestationCount()).to.equal(0n);
expect(Number(await att.nonceOf(keyId))).to.equal(0);
expect((await att.getAttestation(keyId, 0)).exists).to.equal(false);
});
it(`rejects a signature over a DIFFERENT message for scheme ${scheme}`, async function () {
const { rng, pk, keyId, messageHash } = await setup(scheme);
const otherMessage = ethers.keccak256(ethers.toUtf8Bytes("other-msg"));
const otherChallenge = await att.attestChallenge(keyId, 0, otherMessage);
const sigForOther = genuine(scheme, pk, otherChallenge, rng);
// signature is genuine for otherMessage, but we attest messageHash -> reject
await expectRejected(att.attest(keyId, messageHash, sigForOther));
expect(await att.attestationCount()).to.equal(0n);
});
it(`rejects a signature by a DIFFERENT key for scheme ${scheme}`, async function () {
const { rng, keyId, messageHash, challenge } = await setup(scheme);
const otherPk = makePubKey(scheme, rng);
const sigOtherKey = genuine(scheme, otherPk, challenge, rng); // commitment uses other pk
await expectRejected(att.attest(keyId, messageHash, sigOtherKey));
expect(await att.attestationCount()).to.equal(0n);
});
}
it("rejects replay of a consumed (message,signature) after the nonce advances", async function () {
const scheme = 2;
const { rng, pk, keyId, messageHash } = await setup(scheme);
const challenge0 = await att.attestChallenge(keyId, 0, messageHash);
const sig0 = genuine(scheme, pk, challenge0, rng);
await att.attest(keyId, messageHash, sig0); // consumes nonce 0
expect(Number(await att.nonceOf(keyId))).to.equal(1);
// Exact replay of the same (message, signature) now fails: challenge uses nonce 1.
await expect(att.attest(keyId, messageHash, sig0)).to.be.revertedWithCustomError(att, "PQCVerificationFailed");
expect(await att.attestationCount()).to.equal(1n);
expect(Number(await att.nonceOf(keyId))).to.equal(1);
});
it("rejects a stale-nonce signature (signed for an old nonce)", async function () {
const scheme = 2;
const { rng, pk, keyId, messageHash } = await setup(scheme);
// advance nonce to 1 with a genuine attestation
const c0 = await att.attestChallenge(keyId, 0, messageHash);
await att.attest(keyId, messageHash, genuine(scheme, pk, c0, rng));
// now craft a signature for the OLD nonce 0 and try again -> reject
const staleSig = genuine(scheme, pk, c0, rng);
await expect(att.attest(keyId, messageHash, staleSig)).to.be.revertedWithCustomError(att, "PQCVerificationFailed");
});
it("reverts on unknown keyId", async function () {
await expect(att.attest(99, ethers.ZeroHash, "0x" + "00".repeat(80))).to.be.revertedWithCustomError(att, "UnknownKey");
});
it("treats an empty precompile return (pre-fork / stale) as invalid", async function () {
const scheme = 2;
const { rng, pk, keyId, messageHash, challenge } = await setup(scheme);
const sig = genuine(scheme, pk, challenge, rng);
// Wipe the precompile: an empty account returns success + empty returndata.
await network.provider.send("hardhat_setCode", [PRECOMPILE[scheme], "0x"]);
expect(await att.verifySignature(scheme, pk, challenge, sig)).to.equal(false);
await expect(att.attest(keyId, messageHash, sig)).to.be.revertedWithCustomError(att, "PQCVerificationFailed");
expect(await att.attestationCount()).to.equal(0n);
});
});
// -----------------------------------------------------------------------
// Explicit encoding check: the input the contract sends matches the SPEC layout.
// -----------------------------------------------------------------------
describe("encoding", function () {
it("Falcon-1024: mock accepts spec-encoded input and rejects a shifted message", async function () {
const rng = makeRng("enc");
const scheme = 2;
const m = META[scheme];
const pk = ethers.getBytes(makePubKey(scheme, rng));
const message = ethers.getBytes(ethers.keccak256(ethers.toUtf8Bytes("enc-msg")));
const commitment = ethers.getBytes(ethers.keccak256(ethers.concat([pk, message])));
const nonce = rng.bytes(40);
const esig = ethers.concat([new Uint8Array([m.esigHeader]), commitment]);
const sigLen = ethers.getBytes(esig).length;
const sm = ethers.concat([new Uint8Array([(sigLen >> 8) & 0xff, sigLen & 0xff]), nonce, message, esig]);
const input = ethers.hexlify(ethers.concat([pk, sm]));
// direct call to the installed precompile mock
let ret = await ethers.provider.call({ to: PRECOMPILE[scheme], data: input });
expect(BigInt(ret)).to.equal(1n);
// shift the message by one byte -> commitment no longer matches -> invalid
const badMsg = Uint8Array.from(message);
badMsg[0] ^= 0xff;
const smBad = ethers.concat([new Uint8Array([(sigLen >> 8) & 0xff, sigLen & 0xff]), nonce, badMsg, esig]);
ret = await ethers.provider.call({ to: PRECOMPILE[scheme], data: ethers.hexlify(ethers.concat([pk, smBad])) });
expect(BigInt(ret)).to.equal(0n);
});
});
// -----------------------------------------------------------------------
// Seeded invariant fuzz
// -----------------------------------------------------------------------
describe("seeded invariant fuzz", function () {
it("holds forgery / replay / key-binding invariants over many random rounds", async function () {
const rng = makeRng("fuzz-seed-v1");
const ROUNDS = 48;
let expectedCount = 0n;
for (let r = 0; r < ROUNDS; r++) {
const scheme = [1, 2, 3, 4][rng.int(4)];
const pk = makePubKey(scheme, rng);
const keyId = await register(scheme, pk);
const messageHash = ethers.hexlify(rng.bytes(32));
// --- genuine attestation succeeds; state advances by exactly one ---
const nonce0 = Number(await att.nonceOf(keyId));
const challenge0 = await att.attestChallenge(keyId, nonce0, messageHash);
const good = genuine(scheme, pk, challenge0, rng);
await att.attest(keyId, messageHash, good);
expectedCount += 1n;
expect(await att.attestationCount()).to.equal(expectedCount);
expect(Number(await att.nonceOf(keyId))).to.equal(nonce0 + 1);
expect(await att.isValidAttestation(keyId, nonce0, messageHash)).to.equal(true);
// --- a battery of forgeries at the new nonce: ALL rejected, state frozen ---
const nonce1 = Number(await att.nonceOf(keyId));
const challenge1 = await att.attestChallenge(keyId, nonce1, messageHash);
const forgeries = [];
// (a) tampered commitment
forgeries.push(tamperCommitment(scheme, genuine(scheme, pk, challenge1, rng)));
// (b) genuine-but-for-a-different-message
{
const otherMsg = ethers.hexlify(rng.bytes(32));
const otherCh = await att.attestChallenge(keyId, nonce1, otherMsg);
forgeries.push(genuine(scheme, pk, otherCh, rng));
}
// (c) genuine-but-by-a-different-key
{
const otherPk = makePubKey(scheme, rng);
forgeries.push(genuine(scheme, otherPk, challenge1, rng));
}
// (d) replay of the just-consumed signature (challenge was nonce0)
forgeries.push(good);
// (e) stale: signature for the previous nonce
forgeries.push(genuine(scheme, pk, challenge0, rng));
for (const f of forgeries) {
await expect(att.attest(keyId, messageHash, f)).to.be.revertedWithCustomError(att, "PQCVerificationFailed");
}
// invariants unchanged after the whole battery
expect(await att.attestationCount()).to.equal(expectedCount);
expect(Number(await att.nonceOf(keyId))).to.equal(nonce1);
expect((await att.getAttestation(keyId, nonce1)).exists).to.equal(false);
// --- a second genuine attestation at the new nonce still works ---
const good1 = genuine(scheme, pk, challenge1, rng);
await att.attest(keyId, messageHash, good1);
expectedCount += 1n;
expect(await att.attestationCount()).to.equal(expectedCount);
expect(Number(await att.nonceOf(keyId))).to.equal(nonce1 + 1);
}
});
});
});