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

302 lines
14 KiB
JavaScript

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