// ============================================================================= // TDD proof for AereDelegate7702V2 — the scope-hardened EIP-7702 delegate. // // Proves the two session-key SCOPE-ESCAPE facets of the original are CLOSED, // while the legitimate owner / scoped-session-key / passkey paths still work. // // FACET 1 (drain): a selector-scoped key could send the account's whole // native balance to an ARBITRARY target. V2 requires a per-key TARGET // allow-list AND a per-key native SPEND CAP. Proven: over-cap value and // non-allow-listed target both revert; a legit in-scope, in-cap call works; // cumulative spend is enforced across calls. // // FACET 2 (escalation): a key granted the setPasskey / addSessionKey / // revokeSessionKey selector could self-call target=address(this) and pass // onlyOwner. V2 forbids target==address(this) for every session-key call. // Proven: self-call setPasskey and self-call addSessionKey both revert // TargetNotAllowed and the account passkey / grants are untouched. // // PRESERVED: legit scoped call; owner executeBatch; immediate revocation; // expiry; passkey single-use / no-replay / no cross-call reuse. // // EIP-7702 makes the delegating EOA's address == address(this); the owner gate // is msg.sender == address(this). Hardhat does not run a real 7702 tuple, so we // impersonate the deployed delegate's OWN address (getImpersonatedSigner) to // exercise the true onlyOwner path; every other signer is a third party / key. // The P256Verify precompile (0x100) is stubbed via hardhat_setCode. // // Run in ISOLATION (full suite OOMs on unrelated PQC KATs): // npx hardhat test test/delegate7702v2-scope-fix.test.js // // No em-dashes in this file. // ============================================================================= const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const { time } = require("@nomicfoundation/hardhat-network-helpers"); const E = (n) => ethers.parseEther(String(n)); const P256_VERIFY = "0x0000000000000000000000000000000000000100"; const STUB_ACCEPT = "0x600160005260206000f3"; // returns 32-byte 0x..01 const SINK_INIT = "0x60016000f3"; // runtime = STOP: any call/value succeeds, keeps value async function setBal(addr, amountWei) { await network.provider.send("hardhat_setBalance", [addr, "0x" + amountWei.toString(16)]); } async function latestTs() { return Number((await ethers.provider.getBlock("latest")).timestamp); } async function deployRaw(deployer, initCode) { const rc = await (await deployer.sendTransaction({ data: initCode })).wait(); return rc.contractAddress; } // callItem.data = selector (4 bytes) + padWords*32 zero bytes const dataFor = (selector, padWords) => selector + "00".repeat(padWords * 32); describe("AereDelegate7702V2 — session-key scope fix (FACET 1 drain + FACET 2 escalation closed)", function () { this.timeout(0); let signers, F; before(async function () { signers = await ethers.getSigners(); F = await ethers.getContractFactory("AereDelegate7702V2"); }); // Fresh delegate, impersonate its OWN address (the 7702 owner), fund it, and // deploy a universal payable sink to stand in for an allow-listed dApp target. async function stand() { const deployer = signers[0]; const delegate = await F.deploy(); await delegate.waitForDeployment(); const delegateAddr = await delegate.getAddress(); const owner = await ethers.getImpersonatedSigner(delegateAddr); await setBal(delegateAddr, E(1_000_000)); const sink = await deployRaw(deployer, SINK_INIT); const sink2 = await deployRaw(deployer, SINK_INIT); return { delegate, delegateAddr, owner, sink, sink2 }; } // ------------------------------------------------------------------ FACET 1 it("FACET 1 CLOSED: a scoped session key cannot drain to an arbitrary target nor exceed its native spend cap", async function () { const { delegate, delegateAddr, owner, sink, sink2 } = await stand(); const key = signers[1]; const selector = "0x12345678"; const farExpiry = (await latestTs()) + 365 * 24 * 3600; const cap = E(10); // Owner grants: selector + target=sink + spendCap 10. sink2 is NOT allowed. await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, cap, [selector], [sink])).wait(); const accountBal = await ethers.provider.getBalance(delegateAddr); // (1a) Drain attempt: allow-listed target but value = entire balance > cap -> revert. await expect( delegate.connect(key).executeWithSessionKey({ target: sink, value: accountBal, data: dataFor(selector, 1) }) ).to.be.revertedWithCustomError(delegate, "SpendCapExceeded"); // (1b) Arbitrary target (not allow-listed), even within cap -> revert. await expect( delegate.connect(key).executeWithSessionKey({ target: sink2, value: E(1), data: dataFor(selector, 1) }) ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed"); // (1c) Just-over-cap value at an allowed target -> revert. await expect( delegate.connect(key).executeWithSessionKey({ target: sink, value: cap + 1n, data: dataFor(selector, 1) }) ).to.be.revertedWithCustomError(delegate, "SpendCapExceeded"); // Account balance is fully intact after all the drain attempts. expect(await ethers.provider.getBalance(delegateAddr)).to.equal(accountBal); expect(await ethers.provider.getBalance(sink)).to.equal(0n); }); it("FACET 1: cumulative spend cap is enforced across multiple in-scope calls", async function () { const { delegate, owner, sink } = await stand(); const key = signers[1]; const selector = "0x12345678"; const farExpiry = (await latestTs()) + 365 * 24 * 3600; await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(10), [selector], [sink])).wait(); // Spend 6 then 4 -> exactly at cap, both succeed. await (await delegate.connect(key).executeWithSessionKey({ target: sink, value: E(6), data: dataFor(selector, 1) })).wait(); await (await delegate.connect(key).executeWithSessionKey({ target: sink, value: E(4), data: dataFor(selector, 1) })).wait(); expect(await ethers.provider.getBalance(sink)).to.equal(E(10)); // One more wei over the cumulative cap -> revert, no movement. await expect( delegate.connect(key).executeWithSessionKey({ target: sink, value: 1n, data: dataFor(selector, 1) }) ).to.be.revertedWithCustomError(delegate, "SpendCapExceeded"); expect(await ethers.provider.getBalance(sink)).to.equal(E(10)); // Default cap is 0: a key granted no cap cannot move any native value. const key0 = signers[2]; await (await delegate.connect(owner).addSessionKey(key0.address, farExpiry, 0, [selector], [sink])).wait(); await expect( delegate.connect(key0).executeWithSessionKey({ target: sink, value: 1n, data: dataFor(selector, 1) }) ).to.be.revertedWithCustomError(delegate, "SpendCapExceeded"); // But a zero-value in-scope call still works. await (await delegate.connect(key0).executeWithSessionKey({ target: sink, value: 0n, data: dataFor(selector, 1) })).wait(); }); // ------------------------------------------------------------------ FACET 2 it("FACET 2 CLOSED: a session key granted an owner-selector cannot self-call the account to escalate", async function () { const { delegate, delegateAddr, owner } = await stand(); const key = signers[2]; const farExpiry = (await latestTs()) + 365 * 24 * 3600; const setPk = delegate.interface.getFunction("setPasskey").selector; const addSk = delegate.interface.getFunction("addSessionKey").selector; // Owner (mistakenly / via selector collision) grants the setPasskey and // addSessionKey selectors, with a huge cap. Even the target allow-list is // irrelevant: target==address(this) is forbidden outright. To be maximally // adversarial we do NOT even need it allow-listed; try the self-call anyway. await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(1000), [setPk, addSk], [])).wait(); const attackerX = ethers.hexlify(ethers.randomBytes(32)); const attackerY = ethers.hexlify(ethers.randomBytes(32)); const innerSetPk = delegate.interface.encodeFunctionData("setPasskey", [attackerX, attackerY]); expect(innerSetPk.slice(0, 10).toLowerCase()).to.equal(setPk.toLowerCase()); // Self-call setPasskey -> reverts TargetNotAllowed (never reaches onlyOwner). await expect( delegate.connect(key).executeWithSessionKey({ target: delegateAddr, value: 0n, data: innerSetPk }) ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed"); expect(await delegate.passkeyPubX()).to.equal(ethers.ZeroHash); expect(await delegate.passkeyPubY()).to.equal(ethers.ZeroHash); // Self-call addSessionKey (grant attacker its own powerful key) -> reverts. const innerAddSk = delegate.interface.encodeFunctionData("addSessionKey", [ key.address, farExpiry, E(1000), [setPk], [], ]); await expect( delegate.connect(key).executeWithSessionKey({ target: delegateAddr, value: 0n, data: innerAddSk }) ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed"); // Self-call executeBatch -> also blocked (target==address(this)). const innerBatch = delegate.interface.encodeFunctionData("executeBatch", [ [{ target: key.address, value: E(1), data: "0x" }], ]); // grant executeBatch selector too, prove target guard fires regardless. const eb = delegate.interface.getFunction("executeBatch").selector; await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(1000), [eb], [])).wait(); await expect( delegate.connect(key).executeWithSessionKey({ target: delegateAddr, value: 0n, data: innerBatch }) ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed"); }); it("FACET 2: address(this) cannot be added to a session key's target allow-list", async function () { const { delegate, delegateAddr, owner } = await stand(); const key = signers[3]; const farExpiry = (await latestTs()) + 365 * 24 * 3600; const setPk = delegate.interface.getFunction("setPasskey").selector; await expect( delegate.connect(owner).addSessionKey(key.address, farExpiry, E(1), [setPk], [delegateAddr]) ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed"); }); // --------------------------------------------------------------- PRESERVED it("LEGIT: an in-scope session-key call (allowed target + selector + within cap) still works", async function () { const { delegate, owner, sink } = await stand(); const key = signers[4]; const selector = "0xa9059cbb"; // e.g. a swap/transfer selector on the allowed dApp const farExpiry = (await latestTs()) + 24 * 3600; await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(5), [selector], [sink])).wait(); const before = await ethers.provider.getBalance(sink); await (await delegate.connect(key).executeWithSessionKey({ target: sink, value: E(3), data: dataFor(selector, 1) })).wait(); expect((await ethers.provider.getBalance(sink)) - before).to.equal(E(3)); // Wrong selector at the allowed target -> SelectorNotAllowed. await expect( delegate.connect(key).executeWithSessionKey({ target: sink, value: 0n, data: dataFor("0xdeadbeef", 1) }) ).to.be.revertedWithCustomError(delegate, "SelectorNotAllowed"); // View reflects scope. const scope = await delegate.sessionKeyScope(key.address, selector, sink); expect(scope.active).to.equal(true); expect(scope.selectorAllowed).to.equal(true); expect(scope.targetAllowed).to.equal(true); expect(scope.spendCap).to.equal(E(5)); expect(scope.spent).to.equal(E(3)); }); it("PRESERVED: owner executeBatch moves value; third parties are denied", async function () { const { delegate, owner, sink } = await stand(); const before = await ethers.provider.getBalance(sink); await (await delegate.connect(owner).executeBatch([{ target: sink, value: E(7), data: "0x" }])).wait(); expect((await ethers.provider.getBalance(sink)) - before).to.equal(E(7)); // Third party cannot authorize or execute as owner. await expect( delegate.connect(signers[5]).addSessionKey(signers[6].address, (await latestTs()) + 1000, E(1), ["0x12345678"], [sink]) ).to.be.revertedWithCustomError(delegate, "NotEoaOwner"); await expect( delegate.connect(signers[5]).executeBatch([{ target: sink, value: E(1), data: "0x" }]) ).to.be.revertedWithCustomError(delegate, "NotEoaOwner"); }); it("PRESERVED: revocation is immediate and expiry is enforced", async function () { const { delegate, owner, sink } = await stand(); const key = signers[6]; const selector = "0x12345678"; const farExpiry = (await latestTs()) + 365 * 24 * 3600; await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(5), [selector], [sink])).wait(); // works before revoke await (await delegate.connect(key).executeWithSessionKey({ target: sink, value: E(1), data: dataFor(selector, 1) })).wait(); // revoke -> immediate await (await delegate.connect(owner).revokeSessionKey(key.address)).wait(); await expect( delegate.connect(key).executeWithSessionKey({ target: sink, value: 0n, data: dataFor(selector, 1) }) ).to.be.revertedWithCustomError(delegate, "SessionKeyExpired"); // expiry path const key2 = signers[7]; const shortExpiry = (await latestTs()) + 3600; await (await delegate.connect(owner).addSessionKey(key2.address, shortExpiry, E(5), [selector], [sink])).wait(); await time.increase(7200); await network.provider.send("evm_mine"); await expect( delegate.connect(key2).executeWithSessionKey({ target: sink, value: 0n, data: dataFor(selector, 1) }) ).to.be.revertedWithCustomError(delegate, "SessionKeyExpired"); }); it("PRESERVED: passkey single-use — no replay, no cross-call reuse, wrong challenge rejected", async function () { const { delegate, owner, sink } = await stand(); await network.provider.send("hardhat_setCode", [P256_VERIFY, STUB_ACCEPT]); try { await (await delegate.connect(owner).setPasskey(ethers.hexlify(ethers.randomBytes(32)), ethers.hexlify(ethers.randomBytes(32)))).wait(); const chalB64 = (hex32) => Buffer.from(hex32.slice(2), "hex").toString("base64url"); const clientData = (b64) => "0x" + Buffer.from(`{"type":"webauthn.get","challenge":"${b64}","origin":"https://aere.network"}`, "utf8").toString("hex"); const callA = { target: sink, value: E(2), data: "0x" + "ab".repeat(4) }; const expected = await delegate.nextPasskeyChallenge(callA); const sigOk = { r: ethers.hexlify(ethers.randomBytes(32)), s: ethers.hexlify(ethers.randomBytes(32)), authenticatorData: "0x" + "11".repeat(37), clientDataJSON: clientData(chalB64(expected)), }; // wrong challenge const sigBad = { ...sigOk, clientDataJSON: clientData(chalB64(ethers.hexlify(ethers.randomBytes(32)))) }; await expect(delegate.connect(signers[9]).executeWithPasskey(callA, sigBad)).to.be.revertedWithCustomError(delegate, "InvalidChallenge"); // cross-call reuse const callB = { target: sink, value: E(3), data: callA.data }; await expect(delegate.connect(signers[9]).executeWithPasskey(callB, sigOk)).to.be.revertedWithCustomError(delegate, "InvalidChallenge"); // success + nonce advance const nb = await delegate.passkeyNonce(); const sb = await ethers.provider.getBalance(sink); await (await delegate.connect(signers[9]).executeWithPasskey(callA, sigOk)).wait(); expect((await delegate.passkeyNonce()) - nb).to.equal(1n); expect((await ethers.provider.getBalance(sink)) - sb).to.equal(E(2)); // replay identical -> InvalidChallenge (nonce advanced) await expect(delegate.connect(signers[9]).executeWithPasskey(callA, sigOk)).to.be.revertedWithCustomError(delegate, "InvalidChallenge"); } finally { await network.provider.send("hardhat_setCode", [P256_VERIFY, "0x"]); } }); });