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); }); }); });