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.
275 lines
15 KiB
JavaScript
275 lines
15 KiB
JavaScript
const { expect } = require("chai");
|
|
const { ethers } = require("hardhat");
|
|
const tss = require("./helpers/tss");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AereThresholdRegistry -- on-chain registry of t-of-n threshold-ECDSA committees.
|
|
//
|
|
// The signatures in these tests are produced by an INDEPENDENT CJS reimplementation
|
|
// of the same Feldman-VSS DKG + threshold combine that ships in sdk-js/src/mpc. A
|
|
// threshold signature is an ordinary (r,s,v) recovering to the DKG group address, so
|
|
// the contract verifies it with plain ecrecover. Two facts these tests establish:
|
|
// * a valid t-of-n signature verifies; a below-threshold (t-1) combine does NOT.
|
|
// * committee governance (reshare / rotate / slash) is authorized by a threshold
|
|
// signature of the CURRENT committee over a domain-separated intent hash.
|
|
//
|
|
// HONEST caveat mirrored from the contract: on-chain, a threshold sig is
|
|
// indistinguishable from a single-key sig; "t participated" is an off-chain property.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ACTION_RESHARE = ethers.keccak256(ethers.toUtf8Bytes("AereThresholdRegistry.reshare.v1"));
|
|
const ACTION_ROTATE = ethers.keccak256(ethers.toUtf8Bytes("AereThresholdRegistry.rotateKey.v1"));
|
|
const ACTION_SLASH = ethers.keccak256(ethers.toUtf8Bytes("AereThresholdRegistry.slashMember.v1"));
|
|
|
|
const abi = ethers.AbiCoder.defaultAbiCoder();
|
|
|
|
// Random member identity addresses (not the group key; just committee roster).
|
|
function makeMembers(n) {
|
|
const a = [];
|
|
for (let i = 0; i < n; i++) a.push(ethers.Wallet.createRandom().address);
|
|
return a;
|
|
}
|
|
|
|
// Recompute the contract's intent hashes in JS so we can threshold-sign them.
|
|
function reshareIntent(chainId, addr, id, epoch, nonce, newMembers, newT) {
|
|
return ethers.keccak256(
|
|
abi.encode(
|
|
["bytes32", "uint256", "address", "uint256", "uint64", "uint64", "address[]", "uint8"],
|
|
[ACTION_RESHARE, chainId, addr, id, epoch, nonce, newMembers, newT],
|
|
),
|
|
);
|
|
}
|
|
function rotateIntent(chainId, addr, id, epoch, nonce, newPubKey, newMembers, newT) {
|
|
return ethers.keccak256(
|
|
abi.encode(
|
|
["bytes32", "uint256", "address", "uint256", "uint64", "uint64", "bytes32", "address[]", "uint8"],
|
|
[ACTION_ROTATE, chainId, addr, id, epoch, nonce, ethers.keccak256(newPubKey), newMembers, newT],
|
|
),
|
|
);
|
|
}
|
|
function slashIntent(chainId, addr, id, epoch, nonce, member, evidence) {
|
|
return ethers.keccak256(
|
|
abi.encode(
|
|
["bytes32", "uint256", "address", "uint256", "uint64", "uint64", "address", "bytes32"],
|
|
[ACTION_SLASH, chainId, addr, id, epoch, nonce, member, evidence],
|
|
),
|
|
);
|
|
}
|
|
|
|
describe("AereThresholdRegistry", function () {
|
|
let reg, chainId, deployer, guardian, outsider;
|
|
|
|
beforeEach(async function () {
|
|
[deployer, guardian, outsider] = await ethers.getSigners();
|
|
const F = await ethers.getContractFactory("AereThresholdRegistry");
|
|
reg = await F.deploy();
|
|
await reg.waitForDeployment();
|
|
chainId = (await ethers.provider.getNetwork()).chainId;
|
|
});
|
|
|
|
async function register(n, t, guardianAddr = ethers.ZeroAddress) {
|
|
const dkg = tss.runDkg(n, t);
|
|
const members = makeMembers(n);
|
|
const rc = await reg.registerCommittee(members, t, dkg.groupPubKey64, guardianAddr);
|
|
const rcpt = await rc.wait();
|
|
// committeeId is committeeCount after the call
|
|
const id = await reg.committeeCount();
|
|
return { dkg, members, id: Number(id) };
|
|
}
|
|
|
|
it("registers a committee and stores the DKG group key + roster", async function () {
|
|
const { dkg, members, id } = await register(3, 2);
|
|
const c = await reg.getCommittee(id);
|
|
expect(c.threshold).to.equal(2);
|
|
expect(c.size).to.equal(3);
|
|
expect(c.epoch).to.equal(1n);
|
|
expect(c.groupAddress.toLowerCase()).to.equal(dkg.groupAddress.toLowerCase());
|
|
expect(c.groupPubKey.toLowerCase()).to.equal(dkg.groupPubKey64.toLowerCase());
|
|
const onchainMembers = await reg.getMembers(id);
|
|
expect(onchainMembers.map((x) => x.toLowerCase())).to.deep.equal(members.map((x) => x.toLowerCase()));
|
|
for (const m of members) expect(await reg.isMember(id, m)).to.equal(true);
|
|
});
|
|
|
|
it("verifies a valid t-of-n threshold signature (2 of 3)", async function () {
|
|
const { dkg, id } = await register(3, 2);
|
|
const digest = ethers.keccak256(ethers.toUtf8Bytes("withdraw 5 AERE"));
|
|
const sig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[1]]);
|
|
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(true);
|
|
// a different valid quorum {1,3} verifies too (same group key)
|
|
const sig2 = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[2]]);
|
|
expect(await reg.verifyThresholdSignature(id, digest, sig2)).to.equal(true);
|
|
// EIP-1271-style helper returns the magic value
|
|
expect(await reg.isValidGroupSignature(id, digest, sig)).to.equal("0x1626ba7e");
|
|
});
|
|
|
|
it("REJECTS a below-threshold signature (1 of 3 -> wrong key)", async function () {
|
|
const { dkg, id } = await register(3, 2);
|
|
const digest = ethers.keccak256(ethers.toUtf8Bytes("withdraw 5 AERE"));
|
|
// combine with only ONE share -> interpolates to the wrong secret
|
|
const badSig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0]]);
|
|
expect(await reg.verifyThresholdSignature(id, digest, badSig)).to.equal(false);
|
|
});
|
|
|
|
it("REJECTS a signature over a different message, a wrong committee, malleable high-s, and bad v", async function () {
|
|
const { dkg, id } = await register(3, 2);
|
|
const digest = ethers.keccak256(ethers.toUtf8Bytes("msg A"));
|
|
const other = ethers.keccak256(ethers.toUtf8Bytes("msg B"));
|
|
const sig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[1]]);
|
|
// wrong message
|
|
expect(await reg.verifyThresholdSignature(id, other, sig)).to.equal(false);
|
|
// unknown committee id
|
|
expect(await reg.verifyThresholdSignature(999, digest, sig)).to.equal(false);
|
|
// high-s malleated copy must be rejected (s -> N-s, flip v)
|
|
const r = ethers.dataSlice(sig, 0, 32);
|
|
const s = BigInt(ethers.dataSlice(sig, 32, 64));
|
|
const v = parseInt(ethers.dataSlice(sig, 64, 65), 16);
|
|
const highS = tss.N - s;
|
|
const flippedV = v === 27 ? 28 : 27;
|
|
const malleable = ethers.concat([r, ethers.toBeHex(highS, 32), ethers.toBeHex(flippedV, 1)]);
|
|
expect(await reg.verifyThresholdSignature(id, digest, malleable)).to.equal(false);
|
|
// bad v (=29)
|
|
const badV = ethers.concat([ethers.dataSlice(sig, 0, 64), "0x1d"]);
|
|
expect(await reg.verifyThresholdSignature(id, digest, badV)).to.equal(false);
|
|
// wrong length
|
|
expect(await reg.verifyThresholdSignature(id, digest, ethers.dataSlice(sig, 0, 64))).to.equal(false);
|
|
});
|
|
|
|
it("reshares membership (2of3 -> 3of4) keeping the SAME group key, authorized by the current committee", async function () {
|
|
const { dkg, id } = await register(3, 2);
|
|
const newMembers = makeMembers(4);
|
|
const newT = 3;
|
|
const c0 = await reg.getCommittee(id);
|
|
const intent = reshareIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, newMembers, newT);
|
|
const authSig = tss.thresholdSign(ethers.getBytes(intent), [dkg.shares[0], dkg.shares[1]]);
|
|
|
|
await expect(reg.reshareCommittee(id, newMembers, newT, authSig))
|
|
.to.emit(reg, "CommitteeReshared")
|
|
.withArgs(id, 2, newT, 4, ethers.keccak256(abi.encode(["address[]"], [newMembers])));
|
|
|
|
const c = await reg.getCommittee(id);
|
|
expect(c.threshold).to.equal(3);
|
|
expect(c.size).to.equal(4);
|
|
expect(c.epoch).to.equal(2n);
|
|
// SAME group key preserved
|
|
expect(c.groupAddress.toLowerCase()).to.equal(dkg.groupAddress.toLowerCase());
|
|
// new roster installed, old roster cleared
|
|
const onchain = await reg.getMembers(id);
|
|
expect(onchain.map((x) => x.toLowerCase())).to.deep.equal(newMembers.map((x) => x.toLowerCase()));
|
|
|
|
// The SAME group key still signs (shares unchanged in this reshare model).
|
|
const digest = ethers.keccak256(ethers.toUtf8Bytes("post-reshare"));
|
|
const sig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[2]]);
|
|
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(true);
|
|
});
|
|
|
|
it("REJECTS a reshare not authorized by the current committee (forged auth)", async function () {
|
|
const { id } = await register(3, 2);
|
|
const foreign = tss.runDkg(3, 2); // a DIFFERENT group key
|
|
const newMembers = makeMembers(3);
|
|
const c0 = await reg.getCommittee(id);
|
|
const intent = reshareIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, newMembers, 2);
|
|
const badAuth = tss.thresholdSign(ethers.getBytes(intent), [foreign.shares[0], foreign.shares[1]]);
|
|
await expect(reg.reshareCommittee(id, newMembers, 2, badAuth)).to.be.revertedWith("bad committee auth");
|
|
});
|
|
|
|
it("prevents replaying a reshare auth after the epoch/nonce advances", async function () {
|
|
const { dkg, id } = await register(3, 2);
|
|
const newMembers1 = makeMembers(3);
|
|
const c0 = await reg.getCommittee(id);
|
|
const intent1 = reshareIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, newMembers1, 2);
|
|
const auth1 = tss.thresholdSign(ethers.getBytes(intent1), [dkg.shares[0], dkg.shares[1]]);
|
|
await reg.reshareCommittee(id, newMembers1, 2, auth1);
|
|
// replay the exact same call+sig -> epoch/nonce changed so intent no longer matches
|
|
await expect(reg.reshareCommittee(id, newMembers1, 2, auth1)).to.be.revertedWith("bad committee auth");
|
|
});
|
|
|
|
it("rotates to a NEW group key (post-compromise), authorized by the OLD committee", async function () {
|
|
const { dkg, id } = await register(3, 2);
|
|
const fresh = tss.runDkg(5, 3); // brand new committee + key
|
|
const newMembers = makeMembers(5);
|
|
const c0 = await reg.getCommittee(id);
|
|
const intent = rotateIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, fresh.groupPubKey64, newMembers, 3);
|
|
// authorized by the CURRENT (old) committee
|
|
const authSig = tss.thresholdSign(ethers.getBytes(intent), [dkg.shares[1], dkg.shares[2]]);
|
|
await expect(reg.rotateKey(id, fresh.groupPubKey64, newMembers, 3, authSig)).to.emit(reg, "KeyRotated");
|
|
|
|
const c = await reg.getCommittee(id);
|
|
expect(c.groupAddress.toLowerCase()).to.equal(fresh.groupAddress.toLowerCase());
|
|
expect(c.threshold).to.equal(3);
|
|
expect(c.size).to.equal(5);
|
|
expect(c.epoch).to.equal(2n);
|
|
|
|
// OLD key no longer verifies; NEW key does.
|
|
const digest = ethers.keccak256(ethers.toUtf8Bytes("after rotation"));
|
|
const oldSig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[1]]);
|
|
const newSig = tss.thresholdSign(ethers.getBytes(digest), [fresh.shares[0], fresh.shares[1], fresh.shares[2]]);
|
|
expect(await reg.verifyThresholdSignature(id, digest, oldSig)).to.equal(false);
|
|
expect(await reg.verifyThresholdSignature(id, digest, newSig)).to.equal(true);
|
|
});
|
|
|
|
it("rejects rotate to the SAME key (must actually change)", async function () {
|
|
const { dkg, id } = await register(3, 2);
|
|
const newMembers = makeMembers(3);
|
|
const c0 = await reg.getCommittee(id);
|
|
const intent = rotateIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, dkg.groupPubKey64, newMembers, 2);
|
|
const authSig = tss.thresholdSign(ethers.getBytes(intent), [dkg.shares[0], dkg.shares[1]]);
|
|
await expect(reg.rotateKey(id, dkg.groupPubKey64, newMembers, 2, authSig)).to.be.revertedWith("key unchanged");
|
|
});
|
|
|
|
it("slashes a member via committee threshold signature, and marks the member", async function () {
|
|
const { dkg, members, id } = await register(3, 2);
|
|
const target = members[2];
|
|
const evidence = ethers.keccak256(ethers.toUtf8Bytes("equivocation proof blob"));
|
|
const c0 = await reg.getCommittee(id);
|
|
const intent = slashIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, target, evidence);
|
|
const authSig = tss.thresholdSign(ethers.getBytes(intent), [dkg.shares[0], dkg.shares[1]]);
|
|
|
|
expect(await reg.slashed(id, target)).to.equal(false);
|
|
await expect(reg.slashMember(id, target, evidence, authSig))
|
|
.to.emit(reg, "MemberSlashed")
|
|
.withArgs(id, target, evidence, deployer.address);
|
|
expect(await reg.slashed(id, target)).to.equal(true);
|
|
// double slash blocked
|
|
await expect(reg.slashMember(id, target, evidence, authSig)).to.be.revertedWith("already slashed");
|
|
});
|
|
|
|
it("lets the guardian slash and pause/unpause; non-guardian cannot", async function () {
|
|
const { members, id } = await register(3, 2, guardian.address);
|
|
// guardian slash (no threshold sig needed)
|
|
const target = members[1];
|
|
await expect(reg.connect(guardian).slashMember(id, target, ethers.ZeroHash, "0x"))
|
|
.to.emit(reg, "MemberSlashed");
|
|
expect(await reg.slashed(id, target)).to.equal(true);
|
|
|
|
// guardian pause -> verification returns false even for a valid sig
|
|
await expect(reg.connect(guardian).pauseCommittee(id)).to.emit(reg, "CommitteePaused");
|
|
// outsider cannot unpause
|
|
await expect(reg.connect(outsider).unpauseCommittee(id)).to.be.revertedWith("not guardian");
|
|
await expect(reg.connect(guardian).unpauseCommittee(id)).to.emit(reg, "CommitteeUnpaused");
|
|
});
|
|
|
|
it("a PAUSED committee fails verification; ACTIVE again after unpause", async function () {
|
|
const { dkg, id } = await register(3, 2, guardian.address);
|
|
const digest = ethers.keccak256(ethers.toUtf8Bytes("x"));
|
|
const sig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[1]]);
|
|
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(true);
|
|
await reg.connect(guardian).pauseCommittee(id);
|
|
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(false);
|
|
await reg.connect(guardian).unpauseCommittee(id);
|
|
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(true);
|
|
});
|
|
|
|
it("rejects bad registration params (bad t, empty members, dup member, bad pubkey length)", async function () {
|
|
const dkg = tss.runDkg(3, 2);
|
|
const m = makeMembers(3);
|
|
await expect(reg.registerCommittee(m, 0, dkg.groupPubKey64, ethers.ZeroAddress)).to.be.revertedWith("bad t");
|
|
await expect(reg.registerCommittee(m, 4, dkg.groupPubKey64, ethers.ZeroAddress)).to.be.revertedWith("bad t");
|
|
await expect(reg.registerCommittee([], 1, dkg.groupPubKey64, ethers.ZeroAddress)).to.be.revertedWith("bad n");
|
|
const dup = [m[0], m[0], m[1]];
|
|
await expect(reg.registerCommittee(dup, 2, dkg.groupPubKey64, ethers.ZeroAddress)).to.be.revertedWith("dup member");
|
|
// 65-byte (with 0x04 prefix) pubkey is wrong -- contract wants raw 64-byte x||y
|
|
const bad65 = ethers.concat(["0x04", dkg.groupPubKey64]);
|
|
await expect(reg.registerCommittee(m, 2, bad65, ethers.ZeroAddress)).to.be.revertedWith("bad groupPubKey");
|
|
});
|
|
});
|