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

530 lines
24 KiB
JavaScript

const { expect } = require("chai");
const { ethers, network } = require("hardhat");
// ---------------------------------------------------------------------------
// AerePQCKeyRegistry — on-chain proof-of-possession, rotation, revocation,
// owner-only mutation, and seeded invariant fuzz.
//
// Hardhat has no PQC precompile, so we install MockPQCPrecompile at 0x0AE1..0x0AE4
// (hardhat_setCode), exactly as the AerePQCAttestation / AereCryptoRegistry suites do.
// 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). Because the registry builds the SAME wire format as
// AerePQCAttestation (proven separately against the LIVE mainnet precompile via eth_call),
// a positive proof-of-possession here proves the registry encoded the input correctly.
// A registrant who does not hold the key cannot produce a matching commitment.
// ---------------------------------------------------------------------------
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 },
};
const Status = { NONE: 0, ACTIVE: 1, ROTATED: 2, REVOKED: 3 };
const NO_KEY = (1n << 256n) - 1n; // type(uint256).max sentinel
// 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);
}
function commitmentFor(pubKey, message) {
return ethers.keccak256(ethers.concat([pubKey, message]));
}
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` (bytes32) under `pubKey`.
function genuine(scheme, pubKey, message, rng) {
return envelope(scheme, commitmentFor(pubKey, message), rng);
}
// Flip one byte inside the commitment region -> a genuine forgery (mock rejects it).
function tamperCommitment(scheme, sigHex) {
const b = ethers.getBytes(sigHex);
const idx = META[scheme].falcon ? 41 : 0;
b[idx] ^= 0x01;
return ethers.hexlify(b);
}
describe("AerePQCKeyRegistry (proof-of-possession + lifecycle + seeded fuzz)", function () {
let reg, owner, other, mockCode;
before(async function () {
[owner, other] = await ethers.getSigners();
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("AerePQCKeyRegistry");
reg = await R.deploy();
await reg.waitForDeployment();
});
// Build a genuine PoP signature for `signer` registering `pk` under `scheme` at the
// signer's current identity nonce, then send registerKey and return the new keyId.
async function registerGenuine(scheme, pk, signer = owner, rng = makeRng("pop-" + scheme)) {
const nonce = await reg.identityNonce(signer.address);
const challenge = await reg.popChallenge(signer.address, scheme, pk, nonce);
const sig = genuine(scheme, pk, challenge, rng);
const tx = await reg.connect(signer).registerKey(scheme, pk, sig);
const rc = await tx.wait();
const log = rc.logs.map((l) => reg.interface.parseLog(l)).find((p) => p && p.name === "KeyRegistered");
return Number(log.args.keyId);
}
// -----------------------------------------------------------------------
// Registration: proof-of-possession is required and per-scheme validated
// -----------------------------------------------------------------------
describe("registerKey (proof-of-possession)", function () {
it("registers a well-formed key for every scheme with a genuine PoP, assigning ids", async function () {
const rng = makeRng("reg-all");
let expectedId = 0;
for (const scheme of [1, 2, 3, 4]) {
const pk = makePubKey(scheme, rng);
const id = await registerGenuine(scheme, pk, owner, rng);
expect(id).to.equal(expectedId++);
const k = await reg.getKey(id);
expect(k.owner).to.equal(owner.address);
expect(Number(k.scheme)).to.equal(scheme);
expect(Number(k.status)).to.equal(Status.ACTIVE);
expect(k.pubKey).to.equal(pk);
expect(k.rotatedFromId).to.equal(NO_KEY);
expect(k.rotatedToId).to.equal(NO_KEY);
expect(Number(k.registeredBlock)).to.be.greaterThan(0);
}
expect(await reg.keyCount()).to.equal(4n);
expect(Number(await reg.identityNonce(owner.address))).to.equal(4);
});
it("REVERTS when the PoP signature is a forgery (tampered commitment) — key not stored", async function () {
const rng = makeRng("forge");
const scheme = 2;
const pk = makePubKey(scheme, rng);
const nonce = await reg.identityNonce(owner.address);
const challenge = await reg.popChallenge(owner.address, scheme, pk, nonce);
const forged = tamperCommitment(scheme, genuine(scheme, pk, challenge, rng));
await expect(reg.registerKey(scheme, pk, forged)).to.be.revertedWithCustomError(reg, "ProofOfPossessionFailed");
expect(await reg.keyCount()).to.equal(0n);
expect(Number(await reg.identityNonce(owner.address))).to.equal(0);
});
it("REVERTS when the PoP proves possession of a DIFFERENT key", async function () {
const rng = makeRng("wrongkey");
const scheme = 3;
const pk = makePubKey(scheme, rng);
const otherPk = makePubKey(scheme, rng);
const nonce = await reg.identityNonce(owner.address);
const challenge = await reg.popChallenge(owner.address, scheme, pk, nonce);
// Signature is genuine for otherPk over the challenge, but we register pk -> reject.
const sigForOther = genuine(scheme, otherPk, challenge, rng);
await expect(reg.registerKey(scheme, pk, sigForOther)).to.be.revertedWithCustomError(
reg,
"ProofOfPossessionFailed"
);
});
it("PoP is non-transferable: a proof made for owner cannot be replayed by `other`", async function () {
const rng = makeRng("nontransfer");
const scheme = 1;
const pk = makePubKey(scheme, rng);
// Build a genuine proof for OWNER's challenge (owner address + owner nonce).
const ownerNonce = await reg.identityNonce(owner.address);
const ownerChallenge = await reg.popChallenge(owner.address, scheme, pk, ownerNonce);
const proofForOwner = genuine(scheme, pk, ownerChallenge, rng);
// owner can use it.
// ...but `other` cannot: their challenge binds `other`'s address, so it fails.
await expect(reg.connect(other).registerKey(scheme, pk, proofForOwner)).to.be.revertedWithCustomError(
reg,
"ProofOfPossessionFailed"
);
// owner still can (proof is valid for owner).
await expect(reg.connect(owner).registerKey(scheme, pk, proofForOwner)).to.emit(reg, "KeyRegistered");
});
it("PoP cannot be replayed by the same identity after the nonce advances", async function () {
const rng = makeRng("replay");
const scheme = 2;
const pk = makePubKey(scheme, rng);
const nonce0 = await reg.identityNonce(owner.address);
const challenge0 = await reg.popChallenge(owner.address, scheme, pk, nonce0);
const proof0 = genuine(scheme, pk, challenge0, rng);
await reg.registerKey(scheme, pk, proof0); // consumes nonce 0
expect(Number(await reg.identityNonce(owner.address))).to.equal(1);
// Replaying the exact same (pk, proof) now fails: the challenge uses nonce 1.
await expect(reg.registerKey(scheme, pk, proof0)).to.be.revertedWithCustomError(reg, "ProofOfPossessionFailed");
expect(await reg.keyCount()).to.equal(1n);
});
it("rejects an unknown scheme or malformed public key BEFORE touching the precompile", async function () {
await expect(reg.registerKey(5, "0x" + "00".repeat(897), "0x")).to.be.revertedWithCustomError(reg, "InvalidScheme");
// Falcon-1024 correct length, wrong header.
await expect(reg.registerKey(2, "0x09" + "00".repeat(1792), "0x")).to.be.revertedWithCustomError(
reg,
"InvalidPubKey"
);
// ML-DSA-44 wrong length.
await expect(reg.registerKey(3, "0x" + "00".repeat(1311), "0x")).to.be.revertedWithCustomError(
reg,
"InvalidPubKey"
);
});
it("records keysOf and allows multiple keys per identity", async function () {
const rng = makeRng("multi");
const a = await registerGenuine(1, makePubKey(1, rng), owner, rng);
const b = await registerGenuine(3, makePubKey(3, rng), owner, rng);
const list = (await reg.keysOf(owner.address)).map((x) => Number(x));
expect(list).to.deep.equal([a, b]);
});
});
// -----------------------------------------------------------------------
// Rotation
// -----------------------------------------------------------------------
describe("rotateKey", function () {
it("rotates an ACTIVE key to a fresh successor, retiring the old one and linking both", async function () {
const rng = makeRng("rotate");
const oldId = await registerGenuine(1, makePubKey(1, rng), owner, rng);
const newPk = makePubKey(2, rng); // rotate Falcon-512 -> Falcon-1024
const nonce = await reg.identityNonce(owner.address);
const challenge = await reg.popChallenge(owner.address, 2, newPk, nonce);
const sig = genuine(2, newPk, challenge, rng);
await expect(reg.rotateKey(oldId, 2, newPk, sig)).to.emit(reg, "KeyRotated");
const newId = oldId + 1;
const oldK = await reg.getKey(oldId);
const newK = await reg.getKey(newId);
expect(Number(oldK.status)).to.equal(Status.ROTATED);
expect(oldK.rotatedToId).to.equal(BigInt(newId));
expect(Number(newK.status)).to.equal(Status.ACTIVE);
expect(newK.rotatedFromId).to.equal(BigInt(oldId));
expect(await reg.isActiveKey(oldId)).to.equal(false);
expect(await reg.isActiveKey(newId)).to.equal(true);
});
it("only the key owner may rotate", async function () {
const rng = makeRng("rot-owner");
const oldId = await registerGenuine(1, makePubKey(1, rng), owner, rng);
const newPk = makePubKey(1, rng);
const nonce = await reg.identityNonce(other.address);
const challenge = await reg.popChallenge(other.address, 1, newPk, nonce);
const sig = genuine(1, newPk, challenge, rng);
await expect(reg.connect(other).rotateKey(oldId, 1, newPk, sig)).to.be.revertedWithCustomError(
reg,
"NotKeyOwner"
);
});
it("rotation requires a genuine PoP of the NEW key", async function () {
const rng = makeRng("rot-pop");
const oldId = await registerGenuine(2, makePubKey(2, rng), owner, rng);
const newPk = makePubKey(2, rng);
const nonce = await reg.identityNonce(owner.address);
const challenge = await reg.popChallenge(owner.address, 2, newPk, nonce);
const forged = tamperCommitment(2, genuine(2, newPk, challenge, rng));
await expect(reg.rotateKey(oldId, 2, newPk, forged)).to.be.revertedWithCustomError(
reg,
"ProofOfPossessionFailed"
);
// old key untouched.
expect(Number((await reg.getKey(oldId)).status)).to.equal(Status.ACTIVE);
});
it("cannot rotate a non-ACTIVE (already rotated or revoked) key", async function () {
const rng = makeRng("rot-twice");
const oldId = await registerGenuine(1, makePubKey(1, rng), owner, rng);
// First rotation.
const p1 = makePubKey(1, rng);
let nonce = await reg.identityNonce(owner.address);
let ch = await reg.popChallenge(owner.address, 1, p1, nonce);
await reg.rotateKey(oldId, 1, p1, genuine(1, p1, ch, rng));
// Second rotation of the SAME (now ROTATED) old key must fail.
const p2 = makePubKey(1, rng);
nonce = await reg.identityNonce(owner.address);
ch = await reg.popChallenge(owner.address, 1, p2, nonce);
await expect(reg.rotateKey(oldId, 1, p2, genuine(1, p2, ch, rng))).to.be.revertedWithCustomError(
reg,
"KeyNotActive"
);
});
});
// -----------------------------------------------------------------------
// Revocation
// -----------------------------------------------------------------------
describe("revokeKey", function () {
it("owner revokes an ACTIVE key; it becomes terminal and stops verifying", async function () {
const rng = makeRng("rev");
const scheme = 2;
const pk = makePubKey(scheme, rng);
const id = await registerGenuine(scheme, pk, owner, rng);
// verifyWithKey works before revocation.
const msg = ethers.keccak256(ethers.toUtf8Bytes("m"));
const sig = genuine(scheme, pk, msg, rng);
expect(await reg.verifyWithKey(id, msg, sig)).to.equal(true);
await expect(reg.revokeKey(id)).to.emit(reg, "KeyRevoked");
expect(Number(await reg.statusOf(id))).to.equal(Status.REVOKED);
expect(await reg.isActiveKey(id)).to.equal(false);
// verifyWithKey now fail-closes to false even for a genuine signature.
expect(await reg.verifyWithKey(id, msg, sig)).to.equal(false);
});
it("only the owner may revoke, and revoke is idempotent-terminal", async function () {
const rng = makeRng("rev-owner");
const id = await registerGenuine(1, makePubKey(1, rng), owner, rng);
await expect(reg.connect(other).revokeKey(id)).to.be.revertedWithCustomError(reg, "NotKeyOwner");
await reg.revokeKey(id);
await expect(reg.revokeKey(id)).to.be.revertedWithCustomError(reg, "KeyAlreadyRevoked");
});
it("reverts on unknown keyId for revoke / rotate / getKey", async function () {
await expect(reg.revokeKey(999)).to.be.revertedWithCustomError(reg, "UnknownKey");
await expect(reg.rotateKey(999, 1, "0x", "0x")).to.be.revertedWithCustomError(reg, "UnknownKey");
await expect(reg.getKey(999)).to.be.revertedWithCustomError(reg, "UnknownKey");
});
});
// -----------------------------------------------------------------------
// verifyWithKey / verify semantics
// -----------------------------------------------------------------------
describe("verifyWithKey & stateless verify", function () {
it("verifyWithKey returns false (never reverts) for unknown/malformed inputs", async function () {
const rng = makeRng("vwk");
const scheme = 1;
const pk = makePubKey(scheme, rng);
const id = await registerGenuine(scheme, pk, owner, rng);
const msg = ethers.keccak256(ethers.toUtf8Bytes("x"));
// unknown key id.
expect(await reg.verifyWithKey(999, msg, "0x" + "00".repeat(73))).to.equal(false);
// malformed (too-short Falcon) signature -> fail-closed false, no revert.
expect(await reg.verifyWithKey(id, msg, "0x" + "00".repeat(40))).to.equal(false);
// genuine works.
expect(await reg.verifyWithKey(id, msg, genuine(scheme, pk, msg, rng))).to.equal(true);
});
it("a ROTATED key still verifies historically via verifyWithKey", async function () {
const rng = makeRng("vwk-rot");
const oldId = await registerGenuine(1, makePubKey(1, rng), owner, rng);
const oldPk = (await reg.getKey(oldId)).pubKey;
const newPk = makePubKey(1, rng);
const nonce = await reg.identityNonce(owner.address);
const ch = await reg.popChallenge(owner.address, 1, newPk, nonce);
await reg.rotateKey(oldId, 1, newPk, genuine(1, newPk, ch, rng));
const msg = ethers.keccak256(ethers.toUtf8Bytes("hist"));
const sig = genuine(1, oldPk, msg, rng);
expect(Number(await reg.statusOf(oldId))).to.equal(Status.ROTATED);
expect(await reg.verifyWithKey(oldId, msg, sig)).to.equal(true); // historical still valid
});
it("stateless verify() checks any scheme over a raw message; false when precompile wiped", async function () {
const rng = makeRng("stateless");
const scheme = 3;
const pk = makePubKey(scheme, rng);
const msg = ethers.keccak256(ethers.toUtf8Bytes("s"));
const sig = genuine(scheme, pk, msg, rng);
expect(await reg.verify(scheme, pk, msg, sig)).to.equal(true);
await network.provider.send("hardhat_setCode", [PRECOMPILE[scheme], "0x"]);
expect(await reg.verify(scheme, pk, msg, sig)).to.equal(false);
});
it("precompileFor returns the canonical address and reverts on an unknown scheme", async function () {
expect(await reg.precompileFor(1)).to.equal(PRECOMPILE[1]);
expect(await reg.precompileFor(4)).to.equal(PRECOMPILE[4]);
await expect(reg.precompileFor(9)).to.be.revertedWithCustomError(reg, "InvalidScheme");
});
});
// -----------------------------------------------------------------------
// Seeded invariant fuzz
// -----------------------------------------------------------------------
describe("seeded invariant fuzz", function () {
it("proof-of-possession can never be forged/replayed across many random rounds", async function () {
const rng = makeRng("fuzz-pop-v1");
const ROUNDS = 40;
let expectedKeys = 0n;
for (let r = 0; r < ROUNDS; r++) {
const scheme = [1, 2, 3, 4][rng.int(4)];
const pk = makePubKey(scheme, rng);
const signer = rng.int(2) === 0 ? owner : other;
const nonce = await reg.identityNonce(signer.address);
const challenge = await reg.popChallenge(signer.address, scheme, pk, nonce);
// --- a battery of BAD proofs: all revert, nothing stored, nonce frozen ---
const bad = [];
bad.push(tamperCommitment(scheme, genuine(scheme, pk, challenge, rng))); // forgery
{
// genuine proof but for a different key
const otherPk = makePubKey(scheme, rng);
bad.push(genuine(scheme, otherPk, challenge, rng));
}
{
// genuine proof but for a different (wrong) challenge/nonce
const wrongCh = await reg.popChallenge(signer.address, scheme, pk, nonce + 7n);
bad.push(genuine(scheme, pk, wrongCh, rng));
}
for (const f of bad) {
await expect(reg.connect(signer).registerKey(scheme, pk, f)).to.be.revertedWithCustomError(
reg,
"ProofOfPossessionFailed"
);
}
expect(await reg.keyCount()).to.equal(expectedKeys);
expect(await reg.identityNonce(signer.address)).to.equal(nonce);
// --- the genuine proof succeeds exactly once; state advances by one ---
const good = genuine(scheme, pk, challenge, rng);
await reg.connect(signer).registerKey(scheme, pk, good);
expectedKeys += 1n;
expect(await reg.keyCount()).to.equal(expectedKeys);
expect(await reg.identityNonce(signer.address)).to.equal(nonce + 1n);
// --- replay of that exact proof now fails (nonce moved on) ---
await expect(reg.connect(signer).registerKey(scheme, pk, good)).to.be.revertedWithCustomError(
reg,
"ProofOfPossessionFailed"
);
expect(await reg.keyCount()).to.equal(expectedKeys);
}
});
it("revoked keys never verify and are never active, across random lifecycles", async function () {
const rng = makeRng("fuzz-life-v1");
const N = 18;
const rows = [];
for (let i = 0; i < N; i++) {
const scheme = [1, 2, 3, 4][rng.int(4)];
const pk = makePubKey(scheme, rng);
const id = await registerGenuine(scheme, pk, owner, rng);
const msg = ethers.hexlify(rng.bytes(32));
const sig = genuine(scheme, pk, msg, rng);
// random action: 0 leave ACTIVE, 1 rotate away, 2 revoke
const pick = rng.int(3);
let status = Status.ACTIVE;
if (pick === 1) {
const np = makePubKey(scheme, rng);
const n = await reg.identityNonce(owner.address);
const ch = await reg.popChallenge(owner.address, scheme, np, n);
await reg.rotateKey(id, scheme, np, genuine(scheme, np, ch, rng));
status = Status.ROTATED;
} else if (pick === 2) {
await reg.revokeKey(id);
status = Status.REVOKED;
}
rows.push({ id, scheme, pk, msg, sig, status });
}
for (const row of rows) {
const expected = row.status !== Status.REVOKED; // ACTIVE or ROTATED verify; REVOKED never
expect(await reg.verifyWithKey(row.id, row.msg, row.sig)).to.equal(expected);
expect(await reg.isActiveKey(row.id)).to.equal(row.status === Status.ACTIVE);
if (row.status === Status.REVOKED) {
expect(Number(await reg.statusOf(row.id))).to.equal(Status.REVOKED);
}
}
});
});
// -----------------------------------------------------------------------
// Gas (informational)
// -----------------------------------------------------------------------
describe("gas", function () {
it("reports deploy / register / rotate / revoke / verify gas (mock precompile)", async function () {
const R = await ethers.getContractFactory("AerePQCKeyRegistry");
const fresh = await R.deploy();
const deployRc = await fresh.deploymentTransaction().wait();
const rng = makeRng("gas");
// register Falcon-512
const pk1 = makePubKey(1, rng);
let n = await fresh.identityNonce(owner.address);
let ch = await fresh.popChallenge(owner.address, 1, pk1, n);
const regRc = await (await fresh.registerKey(1, pk1, genuine(1, pk1, ch, rng))).wait();
// rotate to Falcon-512
const pk2 = makePubKey(1, rng);
n = await fresh.identityNonce(owner.address);
ch = await fresh.popChallenge(owner.address, 1, pk2, n);
const rotRc = await (await fresh.rotateKey(0, 1, pk2, genuine(1, pk2, ch, rng))).wait();
// register ML-DSA-44
const pk3 = makePubKey(3, rng);
n = await fresh.identityNonce(owner.address);
ch = await fresh.popChallenge(owner.address, 3, pk3, n);
const regMldsaRc = await (await fresh.registerKey(3, pk3, genuine(3, pk3, ch, rng))).wait();
const revRc = await (await fresh.revokeKey(1)).wait();
console.log(" gas — deploy :", deployRc.gasUsed.toString());
console.log(" gas — registerKey Falcon-512:", regRc.gasUsed.toString());
console.log(" gas — rotateKey Falcon-512 :", rotRc.gasUsed.toString());
console.log(" gas — registerKey ML-DSA-44 :", regMldsaRc.gasUsed.toString());
console.log(" gas — revokeKey :", revRc.gasUsed.toString());
expect(deployRc.gasUsed).to.be.greaterThan(0n);
});
});
});