diff --git a/src/SessionGrantLib.sol b/src/SessionGrantLib.sol new file mode 100644 index 0000000..8ea904a --- /dev/null +++ b/src/SessionGrantLib.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (C) 2026 Conrad Japhet +// Use of this software is governed by the Business Source License included in the LICENSE file. +// Change Date: 2029-06-12. Change License: MIT. +pragma solidity ^0.8.24; + +import {Execution} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import {ERC7579Utils, Mode, CallType} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +/// @notice An owner-signed, per-key authorization. The owner signs it ONCE off-chain; it is verified +/// on-chain from calldata with no service in the loop. It extends a session key from a bare +/// signer (bounded only by the USD cap, {SessionHandler-_requireUnrestrictedTarget}, and +/// expiry) to one admissible only for an explicit set of (target, selector) pairs. +struct SessionGrant { + address account; // the wallet; also the EIP-712 verifyingContract + address sessionKey; // must equal SessionHandler.currentSession + bytes32 callsRoot; // merkle root over allowed (target, selector) leaves + uint48 validUntil; // grant expiry (inclusive, matching the key's own <= semantics) + uint256 grantNonce; // owner-bumpable => instant revoke of every prior grant +} + +/** + * @title SessionGrantLib + * @notice Reference implementation for sh-protocol#1: per-key (target, selector) scope for session + * keys, closing the gap the USD cap structurally cannot see (a key may only *value*-spend + * under the cap, but today may call ANY selector on any non-restricted target -- THREAT_MODEL + * ยง3.13). The admission decision is made a *validate-time*, per-call, recomputable check: + * given the same grant and calldata, any party recomputes the identical PASS/REVERT. + * + * @dev This is additive and non-invasive: it does NOT change {SessionHandler} storage or the ABI of + * {execute}. It is written against the SAME decode path {SessionHandler-_guardSessionExecution} + * already uses ({ERC7579Utils-decodeMode} -> SINGLE / BATCH / DELEGATECALL), so it can be lifted + * in directly. The intended wiring, once the owner-signed grant + proofs ride alongside the op: + * + * ```solidity + * // inside SessionHandler, for a non-owner (session-key) execution: + * // 1. account-level checks (domain, key == currentSession, expiry, nonce, owner EIP-712 sig) + * // stay in the account, which already is an EIP712 + Ownable context; then: + * SessionGrantLib.checkScope(execMode, executionCalldata, grant.callsRoot, proofs); + * // existing address-granular guard remains as defence-in-depth: + * // _guardSessionExecution(execMode, executionCalldata); + * ``` + * + * @dev Why merkle, not an on-chain mapping: an owner can authorize an arbitrarily large scope with a + * single off-chain signature (one 65-byte sig + O(log n) proof per call), rather than one owner + * transaction per (target, selector) as {SessionHandler-addAllowedTargets} requires for the + * address-granular list. Leaves are `keccak256(bytes20(target) ++ bytes4(selector))`; the root + * is committed in the signed {SessionGrant}. No JCS / no sha256 -- everything is keccak/`abi` + * native to the verifier, so it stays cheap. + * @dev Fail-closed: a call whose (target, selector) is not provable against `callsRoot` reverts; a + * batch reverts atomically on the first out-of-scope call; delegatecall is refused outright, + * exactly as {SessionHandler-_guardSessionExecution} refuses it (delegated code runs in the + * account's context and could reach the admin surface regardless of the encoded target). + * @author Contributed by Shxnque (Quelum Wilson) against sh-protocol#1. + */ +library SessionGrantLib { + /// @dev A sub-call's (target, selector) is not provable against the grant's committed root. + error SelectorNotGranted(address target, bytes4 selector); + /// @dev Delegatecall is never in scope for a session key (mirrors {SessionHandler}). + error SessionDelegateCallForbidden(); + + /// @dev EIP-712 struct type hash for {SessionGrant}. The account combines this with its own + /// domain separator ({EIP712-_hashTypedDataV4}) to bind the grant to (name, version, + /// chainid, verifyingContract) -- so a grant cannot be replayed across wallets or chains. + bytes32 internal constant GRANT_TYPEHASH = + keccak256("SessionGrant(address account,address sessionKey,bytes32 callsRoot,uint48 validUntil,uint256 grantNonce)"); + + /// @notice The EIP-712 struct hash (inner hash) of a grant. Feed to {EIP712-_hashTypedDataV4}. + function hashStruct(SessionGrant calldata g) internal pure returns (bytes32) { + return keccak256(abi.encode(GRANT_TYPEHASH, g.account, g.sessionKey, g.callsRoot, g.validUntil, g.grantNonce)); + } + + /// @notice The merkle leaf for an admissible (target, selector) pair. + function leaf(address target, bytes4 selector) internal pure returns (bytes32) { + return keccak256(bytes.concat(bytes20(target), bytes4(selector))); + } + + /// @notice The 4-byte selector of an ERC-7579 sub-call's calldata, or 0x00000000 if it carries + /// fewer than 4 bytes (a bare value transfer). A value transfer must be committed as the + /// zero selector to be admissible, so it is never silently allowed. + function selectorOf(bytes calldata cd) internal pure returns (bytes4) { + return cd.length >= 4 ? bytes4(cd[:4]) : bytes4(0); + } + + /// @notice Reverts unless every sub-call in `executionCalldata` is provable against `callsRoot`. + /// @dev Same decode path as {SessionHandler-_guardSessionExecution}; `proofs[i]` is the merkle + /// proof for sub-call `i` (for SINGLE, `proofs[0]`). Pure: decode + merkle verification are + /// all calldata/hash operations, so this is safe to call during ERC-4337 validation. + function checkScope(Mode mode, bytes calldata executionCalldata, bytes32 callsRoot, bytes32[][] calldata proofs) + internal + pure + { + (CallType callType,,,) = ERC7579Utils.decodeMode(mode); + if (callType == ERC7579Utils.CALLTYPE_SINGLE) { + (address target,, bytes calldata cd) = ERC7579Utils.decodeSingle(executionCalldata); + _admit(callsRoot, target, selectorOf(cd), proofs[0]); + } else if (callType == ERC7579Utils.CALLTYPE_BATCH) { + Execution[] calldata batch = ERC7579Utils.decodeBatch(executionCalldata); + for (uint256 i; i < batch.length; ++i) { + _admit(callsRoot, batch[i].target, selectorOf(batch[i].callData), proofs[i]); + } + } else if (callType == ERC7579Utils.CALLTYPE_DELEGATECALL) { + revert SessionDelegateCallForbidden(); + } + } + + function _admit(bytes32 root, address target, bytes4 selector, bytes32[] calldata proof) private pure { + if (!MerkleProof.verifyCalldata(proof, root, leaf(target, selector))) { + // forge-lint: disable-next-line(require-revert-in-loop) + revert SelectorNotGranted(target, selector); + } + } +} diff --git a/test/unit/SessionGrantReferenceTest.t.sol b/test/unit/SessionGrantReferenceTest.t.sol new file mode 100644 index 0000000..34fcaac --- /dev/null +++ b/test/unit/SessionGrantReferenceTest.t.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.33; + +import {Test} from "forge-std/Test.sol"; +import {Execution} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import { + ERC7579Utils, Mode, CallType, ExecType, ModeSelector, ModePayload +} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {SessionGrant, SessionGrantLib} from "../../src/SessionGrantLib.sol"; + +/// @notice Reference account wiring for sh-protocol#1. Demonstrates how {SessionGrantLib} is lifted +/// into a `_guardSessionExecution`-shaped path: the account keeps the stateful, domain-bound +/// checks (EIP-712 domain, key == currentSession, expiry, owner-bumpable nonce, owner sig), +/// then delegates the per-call (target, selector) admission to the library. Additive: this +/// does NOT modify SessionHandler; it proves the mechanism against the SAME ERC-7579 decode +/// path SessionHandler uses. Owner signs ONCE off-chain; verified on-chain from calldata. +contract MockGrantedAccount is EIP712, Ownable { + error BadGrantDomain(); + error BadGrantKey(); + error GrantExpired(); + error GrantRevoked(); + error BadGrantSig(); + + address public currentSession; + uint256 public sessionGrantNonce; + + constructor(address owner_, address session_) EIP712("SessionHandler", "1") Ownable(owner_) { + currentSession = session_; + } + + function bumpGrantNonce() external onlyOwner { + sessionGrantNonce++; + } + + /// @dev Account combines the library's struct hash with ITS OWN EIP-712 domain separator, binding + /// the grant to (name, version, chainid, verifyingContract). + function grantDigest(SessionGrant calldata g) public view returns (bytes32) { + return _hashTypedDataV4(SessionGrantLib.hashStruct(g)); + } + + function leaf(address target, bytes4 selector) public pure returns (bytes32) { + return SessionGrantLib.leaf(target, selector); + } + + /// @dev Same shape as `SessionHandler._guardSessionExecution`, plus the owner-signed grant + proofs. + function guardGrantedExecution( + Mode mode, + bytes calldata executionCalldata, + SessionGrant calldata g, + bytes calldata ownerSig, + bytes32[][] calldata proofs + ) external view { + // Account-level, domain-bound checks (stay in the account, which is the EIP712 + Ownable ctx). + if (g.account != address(this)) revert BadGrantDomain(); + if (g.sessionKey != currentSession) revert BadGrantKey(); + if (block.timestamp > g.validUntil) revert GrantExpired(); + if (g.grantNonce != sessionGrantNonce) revert GrantRevoked(); + if (ECDSA.recover(grantDigest(g), ownerSig) != owner()) revert BadGrantSig(); + + // Per-call (target, selector) admission -> the reusable, recomputable library check. + SessionGrantLib.checkScope(mode, executionCalldata, g.callsRoot, proofs); + } +} + +contract SessionGrantReferenceTest is Test { + MockGrantedAccount acct; + uint256 ownerPk = 0xA11CE; + address owner; + address session = address(0x5E5510); + address target = address(0xDEF1); + + bytes4 constant SEL_A = 0x11111111; + bytes4 constant SEL_B = 0x22222222; + bytes4 constant SEL_C = 0x33333333; // NOT granted + + bytes32 leafA; + bytes32 leafB; + bytes32 root; + + function setUp() public { + owner = vm.addr(ownerPk); + acct = new MockGrantedAccount(owner, session); + leafA = acct.leaf(target, SEL_A); + leafB = acct.leaf(target, SEL_B); + root = _commutative(leafA, leafB); + } + + function _commutative(bytes32 a, bytes32 b) internal pure returns (bytes32) { + return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); + } + + function _grant(address account, uint48 validUntil, uint256 nonce) internal view returns (SessionGrant memory) { + return SessionGrant({ + account: account, + sessionKey: session, + callsRoot: root, + validUntil: validUntil, + grantNonce: nonce + }); + } + + function _sign(uint256 pk, SessionGrant memory g) internal view returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, acct.grantDigest(g)); + return abi.encodePacked(r, s, v); + } + + function _batchMode() internal pure returns (Mode) { + return ERC7579Utils.encodeMode( + ERC7579Utils.CALLTYPE_BATCH, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0)) + ); + } + + function _singleMode() internal pure returns (Mode) { + return ERC7579Utils.encodeMode( + ERC7579Utils.CALLTYPE_SINGLE, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0)) + ); + } + + function _execs(bytes4 sel) internal view returns (bytes memory) { + Execution[] memory e = new Execution[](1); + e[0] = Execution({target: target, value: 0, callData: abi.encodePacked(sel, uint256(1))}); + return ERC7579Utils.encodeBatch(e); + } + + function _proof1(bytes32 sibling) internal pure returns (bytes32[][] memory p) { + p = new bytes32[][](1); + p[0] = new bytes32[](1); + p[0][0] = sibling; + } + + function test_grantedSelector_batch_passes() public view { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + acct.guardGrantedExecution(_batchMode(), _execs(SEL_A), g, _sign(ownerPk, g), _proof1(leafB)); + } + + function test_grantedSelector_single_passes() public view { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + // ERC-7579 SINGLE execution encoding is abi.encodePacked(target, value, callData). + bytes memory ec = abi.encodePacked(target, uint256(0), SEL_A, uint256(1)); + acct.guardGrantedExecution(_singleMode(), ec, g, _sign(ownerPk, g), _proof1(leafB)); + } + + function test_ungrantedSelector_reverts() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_C); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(abi.encodeWithSelector(SessionGrantLib.SelectorNotGranted.selector, target, SEL_C)); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_mixedBatch_reverts_atomic() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Execution[] memory e = new Execution[](2); + e[0] = Execution({target: target, value: 0, callData: abi.encodePacked(SEL_A, uint256(1))}); + e[1] = Execution({target: target, value: 0, callData: abi.encodePacked(SEL_C, uint256(1))}); + bytes memory ec = ERC7579Utils.encodeBatch(e); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = new bytes32[][](2); + pr[0] = new bytes32[](1); + pr[0][0] = leafB; + pr[1] = new bytes32[](1); + pr[1][0] = leafB; + Mode m = _batchMode(); + vm.expectRevert(abi.encodeWithSelector(SessionGrantLib.SelectorNotGranted.selector, target, SEL_C)); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_expiry_inclusive() public { + uint48 exp = uint48(block.timestamp + 100); + SessionGrant memory g = _grant(address(acct), exp, 0); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_A); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); + vm.warp(exp); + acct.guardGrantedExecution(m, ec, g, sig, pr); // == validUntil: passes + vm.warp(uint256(exp) + 1); + vm.expectRevert(MockGrantedAccount.GrantExpired.selector); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_wrongAccount_reverts() public { + SessionGrant memory g = _grant(address(0xBEEF), uint48(block.timestamp + 1 days), 0); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_A); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(MockGrantedAccount.BadGrantDomain.selector); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_staleNonce_reverts() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_A); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); + vm.prank(owner); + acct.bumpGrantNonce(); + vm.expectRevert(MockGrantedAccount.GrantRevoked.selector); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_badSignature_reverts() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_A); + bytes memory badSig = _sign(0xB0B, g); + bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(MockGrantedAccount.BadGrantSig.selector); + acct.guardGrantedExecution(m, ec, g, badSig, pr); + } + + function test_delegatecall_forbidden() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Mode m = ERC7579Utils.encodeMode( + ERC7579Utils.CALLTYPE_DELEGATECALL, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0)) + ); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(SessionGrantLib.SessionDelegateCallForbidden.selector); + acct.guardGrantedExecution(m, hex"", g, sig, pr); + } +}