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.
301 lines
16 KiB
JavaScript
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"]);
|
|
}
|
|
});
|
|
});
|