// AerePQCKeyExchange - on-chain registry + confirmed handshake for ML-KEM-768 key establishment. // // A recipient registers an ML-KEM-768 encapsulation key; an initiator encapsulates a shared secret K // to it OFF-CHAIN (via the ML-KEM precompile 0x0AE6 where activated, or any FIPS-203 library) and // posts the KEM ciphertext hash + a commitment to a one-way, K-derived confirmation opening kc. The // recipient decapsulates, recomputes kc, and confirms by revealing it; the contract checks the commit // and records the confirmation. Unconfirmed handshakes resolve via timeout-cancel. // // This suite drives the on-chain rail directly. It does NOT run real ML-KEM: the contract only checks // byte LENGTHS (ek = 1184, ciphertext = 1088) and HASHES / commit-reveal, so deterministic filler // bytes and a simulated 32-byte shared secret exercise every on-chain branch faithfully. Coverage: // - register / rotate / retire (happy path + every validation revert) // - initiateHandshake (happy path + every validation revert) // - confirmHandshake (happy path, only-responder, mismatch-then-correct, expiry, wrong-state) // - cancel / timeout-cancel (initiator abort, permissionless timeout reaper, cannot cancel confirmed) // // Run: npx hardhat test test/AerePQCKeyExchange.test.js const { expect } = require("chai"); const { ethers } = require("hardhat"); const { time } = require("@nomicfoundation/hardhat-toolbox/network-helpers"); const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); const coder = ethers.AbiCoder.defaultAbiCoder(); const EK_LEN = 1184; // ML-KEM-768 encapsulation key const CT_LEN = 1088; // ML-KEM-768 ciphertext const HOUR = 3600; const DAY = 24 * HOUR; const CONFIRM_DOMAIN = ethers.keccak256(ethers.toUtf8Bytes("AerePQCKeyExchange.v1.confirm")); // deterministic filler bytes (keccak hash-chain), so tests are reproducible. function fill(n, seedText) { const out = new Uint8Array(n); let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText)); 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 ethers.hexlify(out); } describe("AerePQCKeyExchange (ML-KEM-768 registry + confirmed handshake)", function () { let c, cAddr, chainId; let alice, bob, relayer, stranger; // alice = initiator, bob = recipient/responder const WINDOW = 2 * HOUR; // A canonical recipient key and one KEM ciphertext blob. const EK = fill(EK_LEN, "bob-ek-0"); const EK_HASH = ethers.keccak256(EK); const KEM_CT = fill(CT_LEN, "kem-ct-0"); const KEM_CT_HASH = ethers.keccak256(KEM_CT); // A simulated 32-byte ML-KEM shared secret (in production: output of Encaps/Decaps). const K = ethers.keccak256(ethers.toUtf8Bytes("shared-secret-0")); // Off-chain derivation of the confirmation opening kc, mirroring the recommended NatSpec formula: // kc = keccak256(abi.encode(CONFIRM_DOMAIN, chainid, contract, initiator, responder, ekHash, ctHash, K)). function deriveKc(initiator, responder, ekHash, ctHash, k) { return ethers.keccak256( coder.encode( ["bytes32", "uint256", "address", "address", "address", "bytes32", "bytes32", "bytes32"], [CONFIRM_DOMAIN, chainId, cAddr, initiator, responder, ekHash, ctHash, k] ) ); } const commitFor = (kc) => ethers.keccak256(coder.encode(["bytes32"], [kc])); beforeEach(async function () { [alice, bob, relayer, stranger] = await ethers.getSigners(); const F = await ethers.getContractFactory("AerePQCKeyExchange"); c = await F.deploy(); await c.waitForDeployment(); cAddr = await c.getAddress(); chainId = (await ethers.provider.getNetwork()).chainId; // Bob registers his ML-KEM encapsulation key. await (await c.connect(bob).registerKemKey(EK)).wait(); }); // Alice initiates a standard handshake to Bob; returns { id, kc, commit }. async function initiateStd(overrides = {}) { const kc = overrides.kc ?? deriveKc(alice.address, bob.address, EK_HASH, KEM_CT_HASH, K); const o = { responder: bob.address, ekHash: EK_HASH, kemCt: KEM_CT, commit: overrides.commit ?? commitFor(kc), window: WINDOW, ...overrides, }; const id = await c.nextHandshakeId(); await ( await c.connect(alice).initiateHandshake(o.responder, o.ekHash, o.kemCt, o.commit, o.window) ).wait(); return { id, kc, commit: o.commit }; } // ========================================================================= // ML-KEM key registry // ========================================================================= describe("register / rotate / retire", function () { it("registers a key, stores its hash, epoch 1, active", async function () { const k = await c.kemKeyOf(bob.address); expect(k.keyHash).to.equal(EK_HASH); expect(k.epoch).to.equal(1n); expect(k.active).to.equal(true); }); it("rejects a wrong-length encapsulation key", async function () { await expect(c.connect(stranger).registerKemKey(fill(EK_LEN - 1, "short"))) .to.be.revertedWithCustomError(c, "BadEncapKeyLength"); }); it("rejects a second register while a key is already active", async function () { await expect(c.connect(bob).registerKemKey(fill(EK_LEN, "bob-ek-1"))) .to.be.revertedWithCustomError(c, "KeyAlreadyActive"); }); it("rotate bumps the epoch and changes the bound hash", async function () { const ek2 = fill(EK_LEN, "bob-ek-1"); await expect(c.connect(bob).rotateKemKey(ek2)).to.emit(c, "KemKeyRotated"); const k = await c.kemKeyOf(bob.address); expect(k.epoch).to.equal(2n); expect(k.keyHash).to.equal(ethers.keccak256(ek2)); // a handshake that binds the OLD hash is now stale await expect(initiateStd({ ekHash: EK_HASH })).to.be.revertedWithCustomError(c, "StaleEncapKey"); }); it("rotate without an active key reverts", async function () { await expect(c.connect(stranger).rotateKemKey(fill(EK_LEN, "x"))).to.be.revertedWithCustomError(c, "NoActiveKey"); }); it("retire blocks new handshakes but re-register is allowed afterwards", async function () { await (await c.connect(bob).retireKemKey()).wait(); expect((await c.kemKeyOf(bob.address)).active).to.equal(false); await expect(initiateStd()).to.be.revertedWithCustomError(c, "ResponderKeyInactive"); // retire twice reverts await expect(c.connect(bob).retireKemKey()).to.be.revertedWithCustomError(c, "NoActiveKey"); // re-register with a fresh key works and bumps epoch to 2 const ek2 = fill(EK_LEN, "bob-ek-2"); await (await c.connect(bob).registerKemKey(ek2)).wait(); const k = await c.kemKeyOf(bob.address); expect(k.epoch).to.equal(2n); expect(k.active).to.equal(true); expect(k.keyHash).to.equal(ethers.keccak256(ek2)); }); }); // ========================================================================= // initiateHandshake // ========================================================================= describe("initiateHandshake", function () { it("stores ct hash + commitment, arms the deadline, emits the full ciphertext", async function () { const kc = deriveKc(alice.address, bob.address, EK_HASH, KEM_CT_HASH, K); const commit = commitFor(kc); const id = await c.nextHandshakeId(); await expect(c.connect(alice).initiateHandshake(bob.address, EK_HASH, KEM_CT, commit, WINDOW)) .to.emit(c, "HandshakeInitiated") .withArgs(id, alice.address, bob.address, EK_HASH, KEM_CT_HASH, commit, anyValue, KEM_CT); const h = await c.getHandshake(id); expect(h.initiator).to.equal(alice.address); expect(h.responder).to.equal(bob.address); expect(h.state).to.equal(1n); // Initiated expect(h.ekHash).to.equal(EK_HASH); expect(h.kemCtHash).to.equal(KEM_CT_HASH); expect(h.confirmCommit).to.equal(commit); expect(h.confirmOpening).to.equal(ethers.ZeroHash); }); it("rejects an inactive/unknown responder", async function () { await expect(initiateStd({ responder: stranger.address })) .to.be.revertedWithCustomError(c, "ResponderKeyInactive"); }); it("rejects a stale ekHash", async function () { await expect(initiateStd({ ekHash: ethers.ZeroHash })).to.be.revertedWithCustomError(c, "StaleEncapKey"); }); it("rejects a wrong-length KEM ciphertext", async function () { await expect(initiateStd({ kemCt: fill(CT_LEN - 1, "badct") })) .to.be.revertedWithCustomError(c, "BadCiphertextLength"); }); it("rejects a zero commitment", async function () { await expect(initiateStd({ commit: ethers.ZeroHash })).to.be.revertedWithCustomError(c, "ZeroCommitment"); }); it("rejects out-of-range windows", async function () { await expect(initiateStd({ window: 0 })).to.be.revertedWithCustomError(c, "HandshakeWindowOutOfRange"); await expect(initiateStd({ window: 31 * DAY })).to.be.revertedWithCustomError(c, "HandshakeWindowOutOfRange"); }); it("confirmCommitFor view matches the off-chain derivation", async function () { const kc = deriveKc(alice.address, bob.address, EK_HASH, KEM_CT_HASH, K); expect(await c.confirmCommitFor(kc)).to.equal(commitFor(kc)); }); }); // ========================================================================= // confirmHandshake // ========================================================================= describe("confirmHandshake", function () { it("responder reveals the matching opening; handshake becomes Confirmed and records kc", async function () { const { id, kc } = await initiateStd(); await expect(c.connect(bob).confirmHandshake(id, kc)) .to.emit(c, "HandshakeConfirmed") .withArgs(id, bob.address, kc); const h = await c.getHandshake(id); expect(h.state).to.equal(2n); // Confirmed expect(h.confirmOpening).to.equal(kc); }); it("only the designated responder may confirm", async function () { const { id, kc } = await initiateStd(); await expect(c.connect(stranger).confirmHandshake(id, kc)) .to.be.revertedWithCustomError(c, "NotResponder"); await expect(c.connect(alice).confirmHandshake(id, kc)) .to.be.revertedWithCustomError(c, "NotResponder"); }); it("a mismatched opening reverts and leaves state untouched; the correct one then confirms", async function () { const { id, kc } = await initiateStd(); const wrongKc = deriveKc(alice.address, bob.address, EK_HASH, KEM_CT_HASH, ethers.keccak256(ethers.toUtf8Bytes("wrong-K"))); await expect(c.connect(bob).confirmHandshake(id, wrongKc)) .to.be.revertedWithCustomError(c, "ConfirmMismatch"); // still Initiated expect((await c.getHandshake(id)).state).to.equal(1n); // correct opening within the window still works await (await c.connect(bob).confirmHandshake(id, kc)).wait(); expect((await c.getHandshake(id)).state).to.equal(2n); }); it("cannot confirm after the deadline", async function () { const { id, kc } = await initiateStd(); await time.increase(WINDOW + 60); await expect(c.connect(bob).confirmHandshake(id, kc)) .to.be.revertedWithCustomError(c, "HandshakeExpired"); }); it("cannot confirm an unknown handshake", async function () { await expect(c.connect(bob).confirmHandshake(999, ethers.ZeroHash)) .to.be.revertedWithCustomError(c, "UnknownHandshake"); }); it("cannot confirm twice (state no longer Initiated)", async function () { const { id, kc } = await initiateStd(); await (await c.connect(bob).confirmHandshake(id, kc)).wait(); await expect(c.connect(bob).confirmHandshake(id, kc)) .to.be.revertedWithCustomError(c, "WrongState"); }); }); // ========================================================================= // cancel / timeout-cancel // ========================================================================= describe("cancel / timeout-cancel", function () { it("the initiator may abort a pending handshake; confirm then impossible", async function () { const { id, kc } = await initiateStd(); await expect(c.connect(alice).cancelHandshake(id)) .to.emit(c, "HandshakeCancelled") .withArgs(id, alice.address, false); expect((await c.getHandshake(id)).state).to.equal(3n); // Cancelled await expect(c.connect(bob).confirmHandshake(id, kc)) .to.be.revertedWithCustomError(c, "WrongState"); }); it("a stranger cannot cancel before the deadline", async function () { const { id } = await initiateStd(); expect(await c.isTimedOut(id)).to.equal(false); await expect(c.connect(stranger).cancelHandshake(id)) .to.be.revertedWithCustomError(c, "NotCancellable"); }); it("anyone may timeout-cancel once the deadline passes; confirm then impossible", async function () { const { id, kc } = await initiateStd(); await time.increase(WINDOW + 60); expect(await c.isTimedOut(id)).to.equal(true); await expect(c.connect(relayer).cancelHandshake(id)) .to.emit(c, "HandshakeCancelled") .withArgs(id, relayer.address, true); expect((await c.getHandshake(id)).state).to.equal(3n); // Cancelled expect(await c.isTimedOut(id)).to.equal(false); // no longer Initiated await expect(c.connect(bob).confirmHandshake(id, kc)) .to.be.revertedWithCustomError(c, "WrongState"); }); it("cannot cancel a confirmed handshake", async function () { const { id, kc } = await initiateStd(); await (await c.connect(bob).confirmHandshake(id, kc)).wait(); await expect(c.connect(alice).cancelHandshake(id)) .to.be.revertedWithCustomError(c, "WrongState"); }); it("cannot cancel an unknown handshake", async function () { await expect(c.connect(alice).cancelHandshake(12345)) .to.be.revertedWithCustomError(c, "UnknownHandshake"); }); }); });