// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereDelegate7702V2 — scope-hardened EIP-7702 delegation target * @notice Drop-in successor to AereDelegate7702. Same owner and passkey * surface, but session keys are now properly SCOPED so a delegated * key can no longer exceed the authority the owner intended. * * @dev WHY V2 (two closed session-key escapes vs the original): * * FACET 1 - unbounded target + unbounded value. The original bound a * session key ONLY by a 4-byte selector allow-list. There was no * target allow-list and no value cap, so a key granted selector S * could point `target` at ANY contract exposing S and set `value` to * the account's ENTIRE native balance, draining it to an arbitrary * address. V2 requires (a) the target to be in a per-key target * allow-list and (b) the cumulative native value moved by the key to * stay within a per-key spend cap (cap defaults to 0, i.e. a key can * move NO native value unless the owner explicitly grants a cap). * * FACET 2 - self-call privilege escalation. onlyOwner is * msg.sender == address(this), and executeWithSessionKey performs its * outbound call with msg.sender == address(this). A key granted a * selector colliding with an owner-only function (setPasskey / * addSessionKey / revokeSessionKey) could therefore target the account * itself and pass onlyOwner, taking the account over. V2 forbids * target == address(this) for every session-key call, so a session key * can never invoke the account's own owner-gated functions. The owner * (a real EIP-7702 self-transaction) is unaffected. * * STORAGE LAYOUT (7702-safe): as with V1, 7702 transmits only code; * all state reads/writes hit the delegating EOA's own account storage. * This is a FRESH code address; delegating EOAs opt in by re-pointing * their own 7702 tuple (owner-supervised), never migrated for them. */ contract AereDelegate7702V2 { /* --------------------------------- types --------------------------------- */ struct Call { address target; uint256 value; bytes data; } struct SessionKey { uint48 expiry; // unix seconds; key inactive once expiry < now bool active; // owner kill switch; false = revoked uint256 scopeEpoch; // current scope generation; bumped on every add + revoke uint256 spendCap; // cumulative native-value ceiling for this key (0 = no native value) uint256 spent; // cumulative native value already moved by this key (current grant) // Scope is keyed by scopeEpoch so a revoke + re-grant starts a FRESH, // empty scope: only the newly-listed selectors/targets are live and no // historical grant can ever be reactivated (the union-escape fix). mapping(uint256 => mapping(bytes4 => bool)) allowedSelectorsAt; mapping(uint256 => mapping(address => bool)) allowedTargetsAt; } struct P256Signature { bytes32 r; bytes32 s; bytes authenticatorData; bytes clientDataJSON; } /* ------------------------------- storage ------------------------------- */ /// @notice One WebAuthn (P-256) public key per delegating EOA. bytes32 public passkeyPubX; bytes32 public passkeyPubY; /// @notice Anti-replay nonce for passkey-authorised calls. uint256 public passkeyNonce; mapping(address => SessionKey) internal _sessionKeys; /// @dev Reentrancy guard shared by executeBatch and executeWithSessionKey. bool private _entered; /// @notice RIP-7951 P256Verify precompile address on AERE. address internal constant P256_VERIFY = 0x0000000000000000000000000000000000000100; /* ---------------------------------- events --------------------------------- */ event BatchExecuted(uint256 callCount); event SessionKeyAdded(address indexed key, uint48 expiry, uint256 spendCap); event SessionKeyRevoked(address indexed key); event PasskeySet(bytes32 indexed pubX, bytes32 indexed pubY); event PasskeyExecuted(uint256 nonce, address indexed target, uint256 value); /* ---------------------------------- errors -------------------------------- */ error Reentrant(); error NotEoaOwner(); error SessionKeyExpired(); error SelectorNotAllowed(); error TargetNotAllowed(); error SpendCapExceeded(); error CallFailed(uint256 idx, bytes reason); error InvalidPasskeySignature(); error PasskeyNotConfigured(); error InvalidChallenge(); /* -------------------------------- modifiers ------------------------------- */ modifier nonReentrant() { if (_entered) revert Reentrant(); _entered = true; _; _entered = false; } /** * @dev Under EIP-7702 the EOA is identified by address(this), so the owner * check is msg.sender == address(this) (the EOA owner sending a * self-signed tx). Session keys are explicitly barred from targeting * address(this), so they can never satisfy this modifier by self-call. */ modifier onlyOwner() { if (msg.sender != address(this)) revert NotEoaOwner(); _; } /* --------------------------------- core: batch ---------------------------- */ /// @notice Atomic batched-call entrypoint. Authorised by the EOA's own /// ECDSA signature (the tx is signed by the EOA). function executeBatch(Call[] calldata calls) external payable nonReentrant onlyOwner { for (uint256 i = 0; i < calls.length; i++) { (bool ok, bytes memory ret) = calls[i].target.call{value: calls[i].value}(calls[i].data); if (!ok) revert CallFailed(i, ret); } emit BatchExecuted(calls.length); } /* ------------------------------- session keys ----------------------------- */ /** * @notice Register / re-grant a scoped session key. * @param key The session-key address. * @param expiry Unix seconds after which the key cannot act. * @param spendCap Cumulative native-value ceiling for this key. Pass * 0 to forbid the key from moving any native value. * @param allowedSelectors 4-byte selectors the key may invoke. * @param allowedTargets Contract addresses the key may call. address(this) * is rejected: a session key can never call the * account itself. * @dev Each grant is AUTHORITATIVE: it bumps the key's scopeEpoch and writes * the selectors/targets under the fresh epoch, so a re-grant does NOT * accumulate the union of past scopes. Only the newly-listed selectors * and targets are live; expiry, active and spendCap are overwritten, and * the spent counter resets so a re-grant issues a fresh native budget. */ function addSessionKey( address key, uint48 expiry, uint256 spendCap, bytes4[] calldata allowedSelectors, address[] calldata allowedTargets ) external onlyOwner { SessionKey storage sk = _sessionKeys[key]; // Fresh scope generation: everything written below lives ONLY at this // epoch, so no selector/target from any prior grant carries over. uint256 epoch = ++sk.scopeEpoch; sk.expiry = expiry; sk.active = true; sk.spendCap = spendCap; sk.spent = 0; for (uint256 i = 0; i < allowedSelectors.length; i++) { sk.allowedSelectorsAt[epoch][allowedSelectors[i]] = true; } for (uint256 i = 0; i < allowedTargets.length; i++) { // A session key may never target the account itself (escalation guard). if (allowedTargets[i] == address(this)) revert TargetNotAllowed(); sk.allowedTargetsAt[epoch][allowedTargets[i]] = true; } emit SessionKeyAdded(key, expiry, spendCap); } function revokeSessionKey(address key) external onlyOwner { SessionKey storage sk = _sessionKeys[key]; sk.active = false; // Advance the scope generation so the just-revoked scope is orphaned // immediately and can never be read again, even on a later re-grant. sk.scopeEpoch++; emit SessionKeyRevoked(key); } /// @notice Public read for dashboards: selector-level view (V1-compatible). function sessionKeyInfo(address key, bytes4 selector) external view returns (bool active, uint48 expiry, bool selectorAllowed) { SessionKey storage sk = _sessionKeys[key]; return (sk.active, sk.expiry, sk.allowedSelectorsAt[sk.scopeEpoch][selector]); } /// @notice Full per-key scope view: selector + target + native budget. function sessionKeyScope(address key, bytes4 selector, address target) external view returns ( bool active, uint48 expiry, bool selectorAllowed, bool targetAllowed, uint256 spendCap, uint256 spent ) { SessionKey storage sk = _sessionKeys[key]; uint256 epoch = sk.scopeEpoch; return ( sk.active, sk.expiry, sk.allowedSelectorsAt[epoch][selector], sk.allowedTargetsAt[epoch][target], sk.spendCap, sk.spent ); } /// @notice A session key invokes itself as msg.sender. The call is allowed /// only if the key is active, unexpired, the target is in the key's /// target allow-list (and is NOT the account itself), the selector /// is in the key's selector allow-list, and the cumulative native /// value stays within the key's spend cap. function executeWithSessionKey(Call calldata callItem) external payable nonReentrant { SessionKey storage sk = _sessionKeys[msg.sender]; if (!sk.active || sk.expiry < block.timestamp) revert SessionKeyExpired(); // Only the key's CURRENT scope generation authorizes; any target/selector // from a prior (revoked or superseded) grant lives at a stale epoch and // is unreachable here. uint256 epoch = sk.scopeEpoch; // FACET 2 guard: a session key can never call the account itself, so it // can never reach an owner-only function via msg.sender == address(this). if (callItem.target == address(this)) revert TargetNotAllowed(); // FACET 1 guard (target): the target must be explicitly allow-listed. if (!sk.allowedTargetsAt[epoch][callItem.target]) revert TargetNotAllowed(); if (callItem.data.length < 4) revert SelectorNotAllowed(); bytes4 selector; bytes calldata d = callItem.data; assembly { selector := calldataload(d.offset) } if (!sk.allowedSelectorsAt[epoch][selector]) revert SelectorNotAllowed(); // FACET 1 guard (value): cumulative native spend may not exceed the cap. if (callItem.value != 0) { uint256 newSpent = sk.spent + callItem.value; if (newSpent > sk.spendCap) revert SpendCapExceeded(); sk.spent = newSpent; } (bool ok, bytes memory ret) = callItem.target.call{value: callItem.value}(callItem.data); if (!ok) revert CallFailed(0, ret); } /* -------------------------------- passkeys ------------------------------- */ function setPasskey(bytes32 pubX, bytes32 pubY) external onlyOwner { passkeyPubX = pubX; passkeyPubY = pubY; emit PasskeySet(pubX, pubY); } /** * @notice Execute a single call authorised by a WebAuthn (P-256) signature. * @param callItem The call to perform. * @param sig The P-256 signature components. * @dev The signed challenge is computed from the current passkeyNonce * and the call payload. After verification, nonce is incremented * (replay-protection). Unchanged from V1. */ function executeWithPasskey(Call calldata callItem, P256Signature calldata sig) external payable nonReentrant { if (passkeyPubX == bytes32(0) && passkeyPubY == bytes32(0)) revert PasskeyNotConfigured(); // Build the WebAuthn signed digest: SHA256(authenticatorData || SHA256(clientDataJSON)) bytes32 clientDataHash = sha256(sig.clientDataJSON); bytes32 digest = sha256(abi.encodePacked(sig.authenticatorData, clientDataHash)); // Bind the signed clientDataJSON to (this, nonce, call) so an unrelated // WebAuthn signature cannot be replayed as authorisation for a call. bytes32 expectedChallenge = keccak256(abi.encode(address(this), passkeyNonce, callItem)); if (!_clientDataBindsChallenge(sig.clientDataJSON, expectedChallenge)) { revert InvalidChallenge(); } // Verify via RIP-7951 P256Verify at 0x100. // Calldata layout: 160 bytes — msgHash || r || s || qx || qy. bytes memory input = abi.encodePacked(digest, sig.r, sig.s, passkeyPubX, passkeyPubY); (bool ok, bytes memory ret) = P256_VERIFY.staticcall(input); if (!ok || ret.length == 0 || ret[31] != 0x01) revert InvalidPasskeySignature(); // Replay-protect. uint256 nonce = passkeyNonce++; // Perform the call. (bool ok2, bytes memory r2) = callItem.target.call{value: callItem.value}(callItem.data); if (!ok2) revert CallFailed(0, r2); emit PasskeyExecuted(nonce, callItem.target, callItem.value); } /// @notice Convenience for relayers / dApps: recover the expected /// challenge bytes32 for the NEXT passkey-authorised call. function nextPasskeyChallenge(Call calldata callItem) external view returns (bytes32) { return keccak256(abi.encode(address(this), passkeyNonce, callItem)); } /* ----------------------- challenge-binding helpers ---------------------- */ /// @dev base64url alphabet (RFC 4648 §5, no padding). bytes internal constant B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; /// @dev Encode a 32-byte value as 43 chars of base64url (no padding). function _b64url32(bytes32 v) internal pure returns (bytes memory out) { out = new bytes(43); bytes memory tbl = B64URL; uint256 j = 0; for (uint256 i = 0; i + 3 <= 30; i += 3) { uint256 a = uint256(uint8(v[i])); uint256 b = uint256(uint8(v[i + 1])); uint256 c = uint256(uint8(v[i + 2])); out[j++] = tbl[a >> 2]; out[j++] = tbl[((a & 0x03) << 4) | (b >> 4)]; out[j++] = tbl[((b & 0x0f) << 2) | (c >> 6)]; out[j++] = tbl[c & 0x3f]; } // bytes 30, 31 → 3 b64url chars uint256 a2 = uint256(uint8(v[30])); uint256 b2 = uint256(uint8(v[31])); out[j++] = tbl[a2 >> 2]; out[j++] = tbl[((a2 & 0x03) << 4) | (b2 >> 4)]; out[j++] = tbl[(b2 & 0x0f) << 2]; } /// @dev Check that `clientDataJSON` contains the canonical substring /// `"challenge":""`. function _clientDataBindsChallenge(bytes calldata clientDataJSON, bytes32 expected) internal pure returns (bool) { bytes memory enc = _b64url32(expected); // Build the literal pattern `"challenge":""` — 14 fixed chars + 43. bytes memory needle = new bytes(14 + 43); bytes memory prefix = bytes('"challenge":"'); for (uint256 i = 0; i < 13; i++) needle[i] = prefix[i]; for (uint256 i = 0; i < 43; i++) needle[13 + i] = enc[i]; needle[56] = bytes1('"'); return _contains(clientDataJSON, needle); } /// @dev Naive substring search; clientDataJSON is small and needle is fixed /// 57 bytes so gas is bounded. function _contains(bytes calldata hay, bytes memory needle) internal pure returns (bool) { uint256 hLen = hay.length; uint256 nLen = needle.length; if (nLen == 0 || hLen < nLen) return false; uint256 last = hLen - nLen; for (uint256 i = 0; i <= last; i++) { bool match_ = true; for (uint256 j = 0; j < nLen; j++) { if (hay[i + j] != needle[j]) { match_ = false; break; } } if (match_) return true; } return false; } /* ---------------------------------- receive ------------------------------- */ receive() external payable {} }