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