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.
376 lines
16 KiB
Solidity
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 {}
|
|
}
|