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

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