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.
302 lines
14 KiB
JavaScript
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");
|
|
});
|
|
});
|
|
});
|