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.
599 lines
33 KiB
JavaScript
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`);
|
|
});
|
|
});
|