aere-contracts/contracts/delegation/AereDelegate7702V2.sol
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

376 lines
16 KiB
Solidity

// 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":"<base64url(expected)>"`.
function _clientDataBindsChallenge(bytes calldata clientDataJSON, bytes32 expected) internal pure returns (bool) {
bytes memory enc = _b64url32(expected);
// Build the literal pattern `"challenge":"<enc>"` — 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 {}
}