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.
647 lines
35 KiB
JavaScript
647 lines
35 KiB
JavaScript
// AerePQCSocialRecoveryModule — quantum-durable M-of-N recovery for AereModularAccount.
|
|
//
|
|
// Guardians are NIST PQC public keys registered (with on-chain proof-of-possession) in
|
|
// AerePQCKeyRegistry; a recovery is authorized by >= threshold DISTINCT guardian PQC signatures
|
|
// over a domain-separated challenge (account + newOwner + round + chainid), verified on-chain by
|
|
// registry.verifyWithKey, behind the same 48h timelock as the ECDSA social-recovery module.
|
|
//
|
|
// Two verification backends are exercised at 0x0AE1..0x0AE4:
|
|
// 1. MockPQCPrecompile — the repo's faithful mock (accept iff commitment==keccak256(pk||msg)),
|
|
// used for the threshold/timelock/replay/config LOGIC (accept + reject branches). This is the
|
|
// same stand-in AereThresholdAccount and AerePQCKeyRegistry are tested against.
|
|
// 2. RealFalcon512PrecompileAdapter — routes the precompile staticcall into the NIST-KAT-proven
|
|
// AereFalcon512Verifier, so a subset of tests drive GENUINE Falcon-512 signatures (produced by
|
|
// @noble/post-quantum, byte-compatible with AERE's live precompile) through the full module.
|
|
//
|
|
// Run: npx hardhat test test/AerePQCSocialRecoveryModule.test.js
|
|
const { expect } = require("chai");
|
|
const { ethers, network } = require("hardhat");
|
|
const { time } = require("@nomicfoundation/hardhat-toolbox/network-helpers");
|
|
const path = require("path");
|
|
const { pathToFileURL } = require("url");
|
|
|
|
const coder = ethers.AbiCoder.defaultAbiCoder();
|
|
|
|
const PRECOMPILE = {
|
|
1: ethers.getAddress("0x0000000000000000000000000000000000000ae1"),
|
|
2: ethers.getAddress("0x0000000000000000000000000000000000000ae2"),
|
|
3: ethers.getAddress("0x0000000000000000000000000000000000000ae3"),
|
|
4: ethers.getAddress("0x0000000000000000000000000000000000000ae4"),
|
|
};
|
|
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 DAY = 24 * 3600;
|
|
|
|
// ---- deterministic seeded RNG (keccak hash-chain) --------------------------
|
|
function makeRng(seedText) {
|
|
let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText));
|
|
return {
|
|
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 makePubKey(scheme, rng) {
|
|
const m = META[scheme];
|
|
const raw = rng.bytes(m.pkLen);
|
|
if (m.falcon) raw[0] = m.pkHeader;
|
|
return ethers.hexlify(raw);
|
|
}
|
|
function commitmentFor(pubKey, message) {
|
|
return ethers.keccak256(ethers.concat([pubKey, message]));
|
|
}
|
|
// A genuine MOCK envelope binding to `message` under `pubKey` (mock accepts it).
|
|
function genuineMock(scheme, pubKey, message, rng) {
|
|
const m = META[scheme];
|
|
const commitment = commitmentFor(pubKey, message);
|
|
if (m.falcon) {
|
|
const nonce = rng.bytes(40);
|
|
const esig = ethers.concat([new Uint8Array([m.esigHeader]), commitment]);
|
|
return ethers.hexlify(ethers.concat([nonce, esig]));
|
|
}
|
|
const sig = new Uint8Array(m.sigLen);
|
|
sig.set(ethers.getBytes(commitment), 0);
|
|
return ethers.hexlify(sig);
|
|
}
|
|
// Flip one byte in the commitment region -> a forgery (mock rejects it).
|
|
function tamperMock(scheme, sigHex) {
|
|
const b = ethers.getBytes(sigHex);
|
|
b[META[scheme].falcon ? 41 : 0] ^= 0x01;
|
|
return ethers.hexlify(b);
|
|
}
|
|
function encodeInit(keyIds, threshold) {
|
|
return coder.encode(["uint256[]", "uint256"], [keyIds, threshold]);
|
|
}
|
|
|
|
describe("AerePQCSocialRecoveryModule (quantum-durable M-of-N recovery)", function () {
|
|
let mockCode;
|
|
let deployer, root, newOwner, keyOwner, relayer, stranger, ep;
|
|
|
|
before(async function () {
|
|
[deployer, root, newOwner, keyOwner, relayer, stranger, ep] = await ethers.getSigners();
|
|
mockCode = {};
|
|
const F = await ethers.getContractFactory("MockPQCPrecompile");
|
|
for (const s of [1, 2, 3, 4]) {
|
|
const m = await F.deploy(s);
|
|
await m.waitForDeployment();
|
|
mockCode[s] = await ethers.provider.getCode(await m.getAddress());
|
|
}
|
|
});
|
|
|
|
async function installMocks() {
|
|
for (const s of [1, 2, 3, 4]) {
|
|
await network.provider.send("hardhat_setCode", [PRECOMPILE[s], mockCode[s]]);
|
|
}
|
|
}
|
|
|
|
// Deploy registry + module + account, register `schemes.length` guardian keys under keyOwner,
|
|
// install the module as executor with the given threshold. Returns everything the tests need.
|
|
async function deployStack(schemes, threshold, rng) {
|
|
await installMocks();
|
|
|
|
const Reg = await ethers.getContractFactory("AerePQCKeyRegistry");
|
|
const reg = await Reg.deploy();
|
|
await reg.waitForDeployment();
|
|
|
|
const Mod = await ethers.getContractFactory("AerePQCSocialRecoveryModule");
|
|
const mod = await Mod.deploy(await reg.getAddress());
|
|
await mod.waitForDeployment();
|
|
|
|
const Fac = await ethers.getContractFactory("AereModularAccountFactory");
|
|
const factory = await Fac.deploy(ep.address); // EntryPoint unused by the recovery path
|
|
await factory.waitForDeployment();
|
|
|
|
const salt = 1n;
|
|
const acctAddr = await factory["getAddress(address,uint256)"](root.address, salt);
|
|
await (await factory.createAccount(root.address, salt)).wait();
|
|
const account = await ethers.getContractAt("AereModularAccount", acctAddr);
|
|
|
|
// Register guardian keys (each with a genuine mock PoP), owned by keyOwner (!= account).
|
|
const guardians = [];
|
|
for (let i = 0; i < schemes.length; i++) {
|
|
const scheme = schemes[i];
|
|
const pk = makePubKey(scheme, rng);
|
|
const nonce = await reg.identityNonce(keyOwner.address);
|
|
const ch = await reg.popChallenge(keyOwner.address, scheme, pk, nonce);
|
|
const sig = genuineMock(scheme, pk, ch, rng);
|
|
const rc = await (await reg.connect(keyOwner).registerKey(scheme, pk, sig)).wait();
|
|
const log = rc.logs.map((l) => { try { return reg.interface.parseLog(l); } catch { return null; } })
|
|
.find((p) => p && p.name === "KeyRegistered");
|
|
guardians.push({ keyId: BigInt(log.args.keyId), scheme, pk });
|
|
}
|
|
|
|
const keyIds = guardians.map((g) => g.keyId);
|
|
const initData = encodeInit(keyIds, threshold);
|
|
await (await account.connect(root).installModule(2, await mod.getAddress(), initData)).wait();
|
|
|
|
return { reg, mod, factory, account, acctAddr, guardians, keyIds };
|
|
}
|
|
|
|
// Build genuine mock legs for the given guardian indices over the current-round challenge.
|
|
async function mockLegs(mod, guardians, acctAddr, proposedOwner, indices, rng, roundOverride) {
|
|
const round = roundOverride !== undefined ? roundOverride : await mod.recoveryNonce(acctAddr);
|
|
const challenge = await mod.recoveryChallenge(acctAddr, proposedOwner, round);
|
|
return indices.map((idx) => [idx, genuineMock(guardians[idx].scheme, guardians[idx].pk, challenge, rng)]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Config / install
|
|
// =========================================================================
|
|
describe("install + guardian config", function () {
|
|
it("installs as executor, wiring guardian keyIds and threshold", async function () {
|
|
const rng = makeRng("cfg-1");
|
|
const { mod, account, acctAddr, keyIds } = await deployStack([1, 1, 3], 2, rng);
|
|
expect(await account.isModuleInstalled(2, await mod.getAddress(), "0x")).to.equal(true);
|
|
const [t, size, round, pending] = await mod.committee(acctAddr);
|
|
expect(t).to.equal(2n);
|
|
expect(size).to.equal(3n);
|
|
expect(round).to.equal(0n);
|
|
expect(pending).to.equal(false);
|
|
expect((await mod.guardianKeysOf(acctAddr)).map((x) => x)).to.deep.equal(keyIds);
|
|
for (const id of keyIds) expect(await mod.isGuardianKey(acctAddr, id)).to.equal(true);
|
|
});
|
|
|
|
it("SDK challenge derivation matches the contract byte-for-byte", async function () {
|
|
// Reproduces AerePQCSocialRecoveryClient.recoveryChallenge exactly; asserting equality with
|
|
// the on-chain view proves SDK<->contract parity (no drift).
|
|
const rng = makeRng("cfg-parity");
|
|
const { mod, acctAddr } = await deployStack([1, 1, 1], 2, rng);
|
|
const RECOVERY_DOMAIN = ethers.keccak256(ethers.toUtf8Bytes("AerePQCSocialRecoveryModule.v1.recovery"));
|
|
const chainId = (await ethers.provider.getNetwork()).chainId;
|
|
const modAddr = await mod.getAddress();
|
|
for (const round of [0n, 3n, 42n]) {
|
|
const sdk = ethers.keccak256(
|
|
coder.encode(
|
|
["bytes32", "uint256", "address", "address", "address", "uint256"],
|
|
[RECOVERY_DOMAIN, chainId, modAddr, acctAddr, newOwner.address, round]
|
|
)
|
|
);
|
|
expect(sdk).to.equal(await mod.recoveryChallenge(acctAddr, newOwner.address, round));
|
|
}
|
|
});
|
|
|
|
it("rejects duplicate guardian keyId at install (distinctness must be by KEY)", async function () {
|
|
const rng = makeRng("cfg-dup");
|
|
await installMocks();
|
|
const Reg = await ethers.getContractFactory("AerePQCKeyRegistry");
|
|
const reg = await Reg.deploy(); await reg.waitForDeployment();
|
|
const Mod = await ethers.getContractFactory("AerePQCSocialRecoveryModule");
|
|
const mod = await Mod.deploy(await reg.getAddress()); await mod.waitForDeployment();
|
|
const Fac = await ethers.getContractFactory("AereModularAccountFactory");
|
|
const factory = await Fac.deploy(ep.address); await factory.waitForDeployment();
|
|
const acctAddr = await factory["getAddress(address,uint256)"](root.address, 7n);
|
|
await (await factory.createAccount(root.address, 7n)).wait();
|
|
const account = await ethers.getContractAt("AereModularAccount", acctAddr);
|
|
|
|
// one registered key, referenced twice
|
|
const pk = makePubKey(1, rng);
|
|
const nonce = await reg.identityNonce(keyOwner.address);
|
|
const ch = await reg.popChallenge(keyOwner.address, 1, pk, nonce);
|
|
await (await reg.connect(keyOwner).registerKey(1, pk, genuineMock(1, pk, ch, rng))).wait();
|
|
const initData = encodeInit([0n, 0n], 1);
|
|
await expect(account.connect(root).installModule(2, await mod.getAddress(), initData))
|
|
.to.be.revertedWithCustomError(mod, "DuplicateGuardianKey");
|
|
});
|
|
|
|
it("rejects a guardian key OWNED BY the account itself", async function () {
|
|
const rng = makeRng("cfg-self");
|
|
await installMocks();
|
|
const Reg = await ethers.getContractFactory("AerePQCKeyRegistry");
|
|
const reg = await Reg.deploy(); await reg.waitForDeployment();
|
|
const Mod = await ethers.getContractFactory("AerePQCSocialRecoveryModule");
|
|
const mod = await Mod.deploy(await reg.getAddress()); await mod.waitForDeployment();
|
|
const Fac = await ethers.getContractFactory("AereModularAccountFactory");
|
|
const factory = await Fac.deploy(ep.address); await factory.waitForDeployment();
|
|
const acctAddr = await factory["getAddress(address,uint256)"](root.address, 9n);
|
|
await (await factory.createAccount(root.address, 9n)).wait();
|
|
const account = await ethers.getContractAt("AereModularAccount", acctAddr);
|
|
|
|
// The account "self-registers" a key: impersonate the account to call registerKey so ownerOf==account.
|
|
await network.provider.request({ method: "hardhat_impersonateAccount", params: [acctAddr] });
|
|
await network.provider.send("hardhat_setBalance", [acctAddr, "0x56bc75e2d63100000"]);
|
|
const acctSigner = await ethers.getSigner(acctAddr);
|
|
const pk = makePubKey(1, rng);
|
|
const nonce = await reg.identityNonce(acctAddr);
|
|
const ch = await reg.popChallenge(acctAddr, 1, pk, nonce);
|
|
await (await reg.connect(acctSigner).registerKey(1, pk, genuineMock(1, pk, ch, rng))).wait();
|
|
await network.provider.request({ method: "hardhat_stopImpersonatingAccount", params: [acctAddr] });
|
|
|
|
const initData = encodeInit([0n], 1);
|
|
await expect(account.connect(root).installModule(2, await mod.getAddress(), initData))
|
|
.to.be.revertedWithCustomError(mod, "InvalidGuardianKey");
|
|
});
|
|
|
|
it("rejects threshold 0 or > n at install", async function () {
|
|
const rng = makeRng("cfg-thr");
|
|
await installMocks();
|
|
const Reg = await ethers.getContractFactory("AerePQCKeyRegistry");
|
|
const reg = await Reg.deploy(); await reg.waitForDeployment();
|
|
const Mod = await ethers.getContractFactory("AerePQCSocialRecoveryModule");
|
|
const mod = await Mod.deploy(await reg.getAddress()); await mod.waitForDeployment();
|
|
const Fac = await ethers.getContractFactory("AereModularAccountFactory");
|
|
const factory = await Fac.deploy(ep.address); await factory.waitForDeployment();
|
|
const acctAddr = await factory["getAddress(address,uint256)"](root.address, 11n);
|
|
await (await factory.createAccount(root.address, 11n)).wait();
|
|
const account = await ethers.getContractAt("AereModularAccount", acctAddr);
|
|
const pk = makePubKey(1, rng);
|
|
const nonce = await reg.identityNonce(keyOwner.address);
|
|
const ch = await reg.popChallenge(keyOwner.address, 1, pk, nonce);
|
|
await (await reg.connect(keyOwner).registerKey(1, pk, genuineMock(1, pk, ch, rng))).wait();
|
|
await expect(account.connect(root).installModule(2, await mod.getAddress(), encodeInit([0n], 2)))
|
|
.to.be.revertedWithCustomError(mod, "InvalidThreshold");
|
|
await expect(account.connect(root).installModule(2, await mod.getAddress(), encodeInit([0n], 0)))
|
|
.to.be.revertedWithCustomError(mod, "InvalidThreshold");
|
|
});
|
|
|
|
it("rejects a non-active (nonexistent) guardian keyId", async function () {
|
|
const rng = makeRng("cfg-nonexist");
|
|
await installMocks();
|
|
const Reg = await ethers.getContractFactory("AerePQCKeyRegistry");
|
|
const reg = await Reg.deploy(); await reg.waitForDeployment();
|
|
const Mod = await ethers.getContractFactory("AerePQCSocialRecoveryModule");
|
|
const mod = await Mod.deploy(await reg.getAddress()); await mod.waitForDeployment();
|
|
const Fac = await ethers.getContractFactory("AereModularAccountFactory");
|
|
const factory = await Fac.deploy(ep.address); await factory.waitForDeployment();
|
|
const acctAddr = await factory["getAddress(address,uint256)"](root.address, 13n);
|
|
await (await factory.createAccount(root.address, 13n)).wait();
|
|
const account = await ethers.getContractAt("AereModularAccount", acctAddr);
|
|
await expect(account.connect(root).installModule(2, await mod.getAddress(), encodeInit([42n], 1)))
|
|
.to.be.revertedWithCustomError(mod, "GuardianKeyNotActive");
|
|
});
|
|
|
|
it("uninstall clears config but keeps recoveryNonce monotone", async function () {
|
|
const rng = makeRng("cfg-uninstall");
|
|
const { mod, account, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
// schedule then execute one recovery so recoveryNonce advances to 1
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 1], rng);
|
|
await (await mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs)).wait();
|
|
await time.increase(2 * DAY + 60);
|
|
await (await mod.connect(relayer).executeRecovery(acctAddr)).wait();
|
|
expect(await mod.recoveryNonce(acctAddr)).to.equal(1n);
|
|
|
|
// new owner uninstalls
|
|
await (await account.connect(newOwner).uninstallModule(2, await mod.getAddress(), "0x")).wait();
|
|
const [t, size] = await mod.committee(acctAddr);
|
|
expect(t).to.equal(0n);
|
|
expect(size).to.equal(0n);
|
|
expect(await mod.recoveryNonce(acctAddr)).to.equal(1n); // NOT reset
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Recovery flow — mock verifier accept + reject branches
|
|
// =========================================================================
|
|
describe("recovery flow (mock verifier)", function () {
|
|
it("ACCEPT: >= threshold distinct legs schedule; after 48h the root owner is replaced", async function () {
|
|
const rng = makeRng("flow-happy");
|
|
const { mod, account, acctAddr, guardians } = await deployStack([1, 3, 1], 2, rng);
|
|
expect(await account.rootOwner()).to.equal(root.address);
|
|
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 2], rng);
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs))
|
|
.to.emit(mod, "RecoveryScheduled");
|
|
const [, , , pending] = await mod.committee(acctAddr);
|
|
expect(pending).to.equal(true);
|
|
|
|
// premature execution reverts
|
|
await expect(mod.connect(relayer).executeRecovery(acctAddr))
|
|
.to.be.revertedWithCustomError(mod, "TimelockNotElapsed");
|
|
|
|
await time.increase(2 * DAY + 60);
|
|
await expect(mod.connect(relayer).executeRecovery(acctAddr)).to.emit(mod, "RecoveryExecuted");
|
|
expect(await account.rootOwner()).to.equal(newOwner.address);
|
|
expect(await mod.recoveryNonce(acctAddr)).to.equal(1n);
|
|
});
|
|
|
|
it("REJECT: below threshold reverts (only 1 valid leg for a 2-of-3)", async function () {
|
|
const rng = makeRng("flow-below");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [1], rng);
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs))
|
|
.to.be.revertedWithCustomError(mod, "BelowThreshold").withArgs(1, 2);
|
|
});
|
|
|
|
it("REJECT: a forged (tampered) leg is not counted", async function () {
|
|
const rng = makeRng("flow-forge");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const round = await mod.recoveryNonce(acctAddr);
|
|
const challenge = await mod.recoveryChallenge(acctAddr, newOwner.address, round);
|
|
const good0 = genuineMock(1, guardians[0].pk, challenge, rng);
|
|
const forged2 = tamperMock(1, genuineMock(1, guardians[2].pk, challenge, rng));
|
|
const legs = [[0, good0], [2, forged2]]; // 1 valid + 1 forged < 2
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs))
|
|
.to.be.revertedWithCustomError(mod, "BelowThreshold").withArgs(1, 2);
|
|
});
|
|
|
|
it("REJECT: duplicate guardian index counts once (2x guardian 0 for a 2-of-3 fails)", async function () {
|
|
const rng = makeRng("flow-dupidx");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const round = await mod.recoveryNonce(acctAddr);
|
|
const challenge = await mod.recoveryChallenge(acctAddr, newOwner.address, round);
|
|
const s0 = genuineMock(1, guardians[0].pk, challenge, rng);
|
|
const legs = [[0, s0], [0, s0]]; // same guardian twice -> 1 distinct
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs))
|
|
.to.be.revertedWithCustomError(mod, "BelowThreshold").withArgs(1, 2);
|
|
});
|
|
|
|
it("REJECT: wrong-challenge legs (signed for a different newOwner) do not authorize", async function () {
|
|
const rng = makeRng("flow-wrongchal");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const round = await mod.recoveryNonce(acctAddr);
|
|
// guardians sign the challenge for `stranger`, but we submit to recover to `newOwner`
|
|
const wrongChallenge = await mod.recoveryChallenge(acctAddr, stranger.address, round);
|
|
const legs = [0, 1].map((idx) => [idx, genuineMock(1, guardians[idx].pk, wrongChallenge, rng)]);
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs))
|
|
.to.be.revertedWithCustomError(mod, "BelowThreshold").withArgs(0, 2);
|
|
});
|
|
|
|
it("REJECT: legs signed for a different ROUND do not authorize (replay barrier)", async function () {
|
|
const rng = makeRng("flow-winground");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
// legs for round 5 while the account is at round 0
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 1], rng, 5n);
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs))
|
|
.to.be.revertedWithCustomError(mod, "BelowThreshold").withArgs(0, 2);
|
|
});
|
|
|
|
it("REJECT: out-of-range guardian index is ignored", async function () {
|
|
const rng = makeRng("flow-oor");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const round = await mod.recoveryNonce(acctAddr);
|
|
const challenge = await mod.recoveryChallenge(acctAddr, newOwner.address, round);
|
|
const legs = [
|
|
[0, genuineMock(1, guardians[0].pk, challenge, rng)],
|
|
[99, genuineMock(1, guardians[0].pk, challenge, rng)], // idx 99 >= size 3
|
|
];
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs))
|
|
.to.be.revertedWithCustomError(mod, "BelowThreshold").withArgs(1, 2);
|
|
});
|
|
|
|
it("REJECT: a REVOKED guardian key can no longer authorize", async function () {
|
|
const rng = makeRng("flow-revoke");
|
|
const { reg, mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
// keyOwner revokes guardian 2's key
|
|
await (await reg.connect(keyOwner).revokeKey(guardians[2].keyId)).wait();
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 2], rng);
|
|
// guardian 2 revoked -> only guardian 0 counts -> below 2
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs))
|
|
.to.be.revertedWithCustomError(mod, "BelowThreshold").withArgs(1, 2);
|
|
// but 0 + 1 still works
|
|
const ok = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 1], rng);
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, ok))
|
|
.to.emit(mod, "RecoveryScheduled");
|
|
});
|
|
|
|
it("mixed-scheme committee (Falcon-512 + ML-DSA-44) authorizes", async function () {
|
|
const rng = makeRng("flow-mixed");
|
|
const { mod, account, acctAddr, guardians } = await deployStack([1, 3, 4], 2, rng); // Falcon512, ML-DSA-44, SLH-DSA-128s
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 1], rng);
|
|
await (await mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs)).wait();
|
|
await time.increase(2 * DAY + 60);
|
|
await (await mod.connect(relayer).executeRecovery(acctAddr)).wait();
|
|
expect(await account.rootOwner()).to.equal(newOwner.address);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Timelock, cancel, replay, permissions
|
|
// =========================================================================
|
|
describe("timelock / cancel / replay", function () {
|
|
it("owner can cancel a pending recovery; execution then impossible; nonce advanced", async function () {
|
|
const rng = makeRng("cancel-owner");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 1], rng);
|
|
await (await mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs)).wait();
|
|
await expect(mod.connect(root).cancelRecovery(acctAddr)).to.emit(mod, "RecoveryCancelled");
|
|
expect(await mod.recoveryNonce(acctAddr)).to.equal(1n);
|
|
await time.increase(2 * DAY + 60);
|
|
await expect(mod.connect(relayer).executeRecovery(acctAddr))
|
|
.to.be.revertedWithCustomError(mod, "NoActiveRecovery");
|
|
});
|
|
|
|
it("stranger cannot cancel", async function () {
|
|
const rng = makeRng("cancel-stranger");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 1], rng);
|
|
await (await mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs)).wait();
|
|
await expect(mod.connect(stranger).cancelRecovery(acctAddr))
|
|
.to.be.revertedWithCustomError(mod, "NotOwner");
|
|
});
|
|
|
|
it("cannot schedule a second recovery while one is pending", async function () {
|
|
const rng = makeRng("double-sched");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 1], rng);
|
|
await (await mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs)).wait();
|
|
const legs2 = await mockLegs(mod, guardians, acctAddr, stranger.address, [0, 1], rng);
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, stranger.address, legs2))
|
|
.to.be.revertedWithCustomError(mod, "RecoveryAlreadyActive");
|
|
});
|
|
|
|
it("legs are single-use: after execute, re-submitting the same legs fails (round advanced)", async function () {
|
|
const rng = makeRng("single-use");
|
|
const { mod, account, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 1], rng, 0n);
|
|
await (await mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs)).wait();
|
|
await time.increase(2 * DAY + 60);
|
|
await (await mod.connect(relayer).executeRecovery(acctAddr)).wait();
|
|
expect(await account.rootOwner()).to.equal(newOwner.address);
|
|
// round is now 1; the same round-0 legs no longer verify
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs))
|
|
.to.be.revertedWithCustomError(mod, "BelowThreshold").withArgs(0, 2);
|
|
});
|
|
|
|
it("scheduleRecovery on an unconfigured account reverts NotConfigured", async function () {
|
|
await installMocks();
|
|
const Reg = await ethers.getContractFactory("AerePQCKeyRegistry");
|
|
const reg = await Reg.deploy(); await reg.waitForDeployment();
|
|
const Mod = await ethers.getContractFactory("AerePQCSocialRecoveryModule");
|
|
const mod = await Mod.deploy(await reg.getAddress()); await mod.waitForDeployment();
|
|
await expect(mod.connect(relayer).scheduleRecovery(stranger.address, newOwner.address, []))
|
|
.to.be.revertedWithCustomError(mod, "NotConfigured");
|
|
});
|
|
|
|
it("execute fires only after a FULL 48h (one hour short still reverts)", async function () {
|
|
const rng = makeRng("timelock-exact");
|
|
const { mod, acctAddr, guardians } = await deployStack([1, 1, 1], 2, rng);
|
|
const legs = await mockLegs(mod, guardians, acctAddr, newOwner.address, [0, 1], rng);
|
|
await (await mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs)).wait();
|
|
await time.increase(2 * DAY - 3600); // 1h short
|
|
await expect(mod.connect(relayer).executeRecovery(acctAddr))
|
|
.to.be.revertedWithCustomError(mod, "TimelockNotElapsed");
|
|
await time.increase(3600 + 5);
|
|
await (await mod.connect(relayer).executeRecovery(acctAddr)).wait();
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// REAL Falcon-512 crypto: genuine signatures via @noble, verified by the
|
|
// NIST-KAT-proven AereFalcon512Verifier behind a precompile-format adapter.
|
|
// =========================================================================
|
|
describe("real Falcon-512 legs (KAT-proven verifier, no mock)", function () {
|
|
let falcon512, verifier, verifierAddr, adapterCode;
|
|
const kat = require("./falcon512_kat0.json");
|
|
|
|
before(async function () {
|
|
const nobleFalcon = path.resolve(
|
|
__dirname, "../../sdk-js/node_modules/@noble/post-quantum/falcon.js"
|
|
);
|
|
({ falcon512 } = await import(pathToFileURL(nobleFalcon).href));
|
|
|
|
const V = await ethers.getContractFactory("AereFalcon512Verifier");
|
|
verifier = await V.deploy();
|
|
await verifier.waitForDeployment();
|
|
verifierAddr = await verifier.getAddress();
|
|
|
|
const A = await ethers.getContractFactory("RealFalcon512PrecompileAdapter");
|
|
const adapter = await A.deploy(verifierAddr);
|
|
await adapter.waitForDeployment();
|
|
adapterCode = await ethers.provider.getCode(await adapter.getAddress());
|
|
});
|
|
|
|
// The pure-Solidity AereFalcon512Verifier standing in for the native precompile costs ~10.7M
|
|
// gas PER leg (on mainnet the native precompile is cheap). With that stand-in, ethers' auto
|
|
// gas-estimation does not leave the 63/64 headroom the nested precompile staticcalls need, so a
|
|
// leg can soft-fail. Pass an explicit generous gasLimit for the heavy scheduleRecovery calls
|
|
// (the hardhat network already permits a 2e9 block gas limit). This is a TEST HARNESS
|
|
// accommodation for the expensive stand-in, not a contract concern.
|
|
const HEAVY = { gasLimit: 400_000_000 };
|
|
|
|
// noble detached (0x39 || nonce(40) || compressed) -> AERE envelope (nonce(40) || 0x29 || compressed)
|
|
function realEnvelope(detached) {
|
|
const nonce = detached.slice(1, 41);
|
|
const compressed = detached.slice(41);
|
|
const esig = new Uint8Array(1 + compressed.length);
|
|
esig[0] = 0x29;
|
|
esig.set(compressed, 1);
|
|
return ethers.hexlify(ethers.concat([nonce, esig]));
|
|
}
|
|
|
|
async function installRealFalconAt0AE1() {
|
|
await network.provider.send("hardhat_setCode", [PRECOMPILE[1], adapterCode]);
|
|
}
|
|
|
|
// Produce a genuine Falcon-512 leg over `challenge` for guardian `idx`, and confirm (via an
|
|
// eth_call with ample gas) that the FULL registry->adapter->verifier path accepts it before we
|
|
// rely on it. Falcon signing is randomized, so on the rare miss we re-sign (a real client does
|
|
// the same). Returns [idx, envelope].
|
|
async function signValidRealLeg(reg, keyId, kp, idx, challenge) {
|
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
const env = realEnvelope(falcon512.sign(ethers.getBytes(challenge), kp.secretKey));
|
|
if (await reg.verifyWithKey(keyId, challenge, env)) return [idx, env];
|
|
}
|
|
throw new Error(`could not produce a verifying Falcon leg for guardian ${idx}`);
|
|
}
|
|
|
|
it("the precompile adapter accepts the OFFICIAL NIST Falcon-512 KAT vector 0", async function () {
|
|
await installRealFalconAt0AE1();
|
|
// raw precompile call: input = pk(897) || sm ; returns 32-byte 0x..01 on accept
|
|
const input = ethers.concat([kat.pk, kat.sm]);
|
|
const ret = await ethers.provider.call({ to: PRECOMPILE[1], data: input });
|
|
expect(BigInt(ret)).to.equal(1n);
|
|
// sanity: the backing verifier accepts it directly too
|
|
expect(await verifier.verifySignedMessage(kat.pk, kat.sm)).to.equal(true);
|
|
});
|
|
|
|
it("end-to-end 2-of-3 recovery with GENUINE Falcon-512 signatures over the challenge", async function () {
|
|
await installRealFalconAt0AE1();
|
|
|
|
const Reg = await ethers.getContractFactory("AerePQCKeyRegistry");
|
|
const reg = await Reg.deploy(); await reg.waitForDeployment();
|
|
const Mod = await ethers.getContractFactory("AerePQCSocialRecoveryModule");
|
|
const mod = await Mod.deploy(await reg.getAddress()); await mod.waitForDeployment();
|
|
const Fac = await ethers.getContractFactory("AereModularAccountFactory");
|
|
const factory = await Fac.deploy(ep.address); await factory.waitForDeployment();
|
|
const acctAddr = await factory["getAddress(address,uint256)"](root.address, 100n);
|
|
await (await factory.createAccount(root.address, 100n)).wait();
|
|
const account = await ethers.getContractAt("AereModularAccount", acctAddr);
|
|
|
|
// Generate 3 real Falcon-512 guardian keypairs, register each with a GENUINE PoP.
|
|
const kps = [];
|
|
const keyIds = [];
|
|
for (let i = 0; i < 3; i++) {
|
|
const seed = new Uint8Array(48).fill(100 + i);
|
|
const kp = falcon512.keygen(seed);
|
|
const pk = ethers.hexlify(kp.publicKey);
|
|
const nonce = await reg.identityNonce(keyOwner.address);
|
|
const ch = await reg.popChallenge(keyOwner.address, 1, pk, nonce);
|
|
const detached = falcon512.sign(ethers.getBytes(ch), kp.secretKey);
|
|
const rc = await (await reg.connect(keyOwner).registerKey(1, pk, realEnvelope(detached), HEAVY)).wait();
|
|
const log = rc.logs.map((l) => { try { return reg.interface.parseLog(l); } catch { return null; } })
|
|
.find((p) => p && p.name === "KeyRegistered");
|
|
kps.push(kp);
|
|
keyIds.push(BigInt(log.args.keyId));
|
|
}
|
|
|
|
await (await account.connect(root).installModule(2, await mod.getAddress(), encodeInit(keyIds, 2))).wait();
|
|
|
|
// Two guardians (0 and 2) sign the CURRENT-round recovery challenge with real Falcon keys.
|
|
const round = await mod.recoveryNonce(acctAddr);
|
|
const challenge = await mod.recoveryChallenge(acctAddr, newOwner.address, round);
|
|
const legs = [
|
|
await signValidRealLeg(reg, keyIds[0], kps[0], 0, challenge),
|
|
await signValidRealLeg(reg, keyIds[2], kps[2], 2, challenge),
|
|
];
|
|
|
|
const tx = await mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs, HEAVY);
|
|
const rc = await tx.wait();
|
|
console.log(" gas — scheduleRecovery (2 real Falcon-512 legs):", rc.gasUsed.toString());
|
|
|
|
await time.increase(2 * DAY + 60);
|
|
await (await mod.connect(relayer).executeRecovery(acctAddr)).wait();
|
|
expect(await account.rootOwner()).to.equal(newOwner.address);
|
|
});
|
|
|
|
it("REJECT: a genuine Falcon-512 leg for the WRONG newOwner does not authorize", async function () {
|
|
await installRealFalconAt0AE1();
|
|
const Reg = await ethers.getContractFactory("AerePQCKeyRegistry");
|
|
const reg = await Reg.deploy(); await reg.waitForDeployment();
|
|
const Mod = await ethers.getContractFactory("AerePQCSocialRecoveryModule");
|
|
const mod = await Mod.deploy(await reg.getAddress()); await mod.waitForDeployment();
|
|
const Fac = await ethers.getContractFactory("AereModularAccountFactory");
|
|
const factory = await Fac.deploy(ep.address); await factory.waitForDeployment();
|
|
const acctAddr = await factory["getAddress(address,uint256)"](root.address, 101n);
|
|
await (await factory.createAccount(root.address, 101n)).wait();
|
|
const account = await ethers.getContractAt("AereModularAccount", acctAddr);
|
|
|
|
const kps = []; const keyIds = [];
|
|
for (let i = 0; i < 2; i++) {
|
|
const seed = new Uint8Array(48).fill(200 + i);
|
|
const kp = falcon512.keygen(seed);
|
|
const pk = ethers.hexlify(kp.publicKey);
|
|
const nonce = await reg.identityNonce(keyOwner.address);
|
|
const ch = await reg.popChallenge(keyOwner.address, 1, pk, nonce);
|
|
const detached = falcon512.sign(ethers.getBytes(ch), kp.secretKey);
|
|
const rc = await (await reg.connect(keyOwner).registerKey(1, pk, realEnvelope(detached), HEAVY)).wait();
|
|
const log = rc.logs.map((l) => { try { return reg.interface.parseLog(l); } catch { return null; } })
|
|
.find((p) => p && p.name === "KeyRegistered");
|
|
kps.push(kp); keyIds.push(BigInt(log.args.keyId));
|
|
}
|
|
await (await account.connect(root).installModule(2, await mod.getAddress(), encodeInit(keyIds, 2))).wait();
|
|
|
|
const round = await mod.recoveryNonce(acctAddr);
|
|
// GENUINE Falcon-512 signatures, but over the challenge for `stranger`; submitted to recover
|
|
// to `newOwner`. Each is well-formed (verifies over the wrong challenge) but binds the wrong
|
|
// target owner, so the module counts 0 over the real challenge.
|
|
const wrong = await mod.recoveryChallenge(acctAddr, stranger.address, round);
|
|
const legs = [
|
|
await signValidRealLeg(reg, keyIds[0], kps[0], 0, wrong),
|
|
await signValidRealLeg(reg, keyIds[1], kps[1], 1, wrong),
|
|
];
|
|
await expect(mod.connect(relayer).scheduleRecovery(acctAddr, newOwner.address, legs, HEAVY))
|
|
.to.be.revertedWithCustomError(mod, "BelowThreshold").withArgs(0, 2);
|
|
});
|
|
});
|
|
});
|