aere-contracts/test/delegate7702v2-scope-fix.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

301 lines
16 KiB
JavaScript

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