// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {SignatureCheckerLib} from "solady/utils/SignatureCheckerLib.sol"; import {IValidator, IHook} from "kernel/interfaces/IERC7579Modules.sol"; import {PackedUserOperation} from "kernel/interfaces/PackedUserOperation.sol"; import { SIG_VALIDATION_SUCCESS_UINT, SIG_VALIDATION_FAILED_UINT, MODULE_TYPE_VALIDATOR, MODULE_TYPE_HOOK, ERC1271_MAGICVALUE, ERC1271_INVALID } from "kernel/types/Constants.sol"; import {NoExtraValidator} from "../NoExtraValidator.sol"; import {RootBinding} from "../RootBinding.sol"; /// A Kernel v3.1 root validator whose owner is a CONTRACT that answers /// ERC-1271 — a Safe, in the case this was built for. /// /// The ONE line that differs from the vendored `ECDSAValidator` is the whole /// point: /// /// ECDSAValidator: owner == ECDSA.recover(hash, sig) /// here: SignatureCheckerLib.isValidSignatureNowCalldata(owner, hash, sig) /// /// `ECDSA.recover` returns `keccak(secp256k1 pubkey)[12:]`, which a Safe's /// address is not and cannot become, so PASSKEY_OWNER.md F1 holds against the /// vendored validator and no amount of Safe-side work fixes it. Swapping in the /// checker moves the decision to the owner, where a contract can answer. /// /// Two properties of solady's checker matter more than the delegation itself: /// /// 1. It does not revert. `ECDSA.recover` reverts `InvalidSignature()` /// (0x8baa579f) on anything that is not 64 or 65 bytes, and a Safe's /// multi-owner blob is 65*n. A revert inside `validateUserOp` surfaces as /// `FailedOpWithRevert(..., "AA23 reverted", ...)` — bundler rejection /// rather than a clean SIG_VALIDATION_FAILED. The checker returns false. /// 2. It falls back to ECDSA when the owner has no code, so an EOA owner /// keeps working. That makes this a SUPERSET of ECDSAValidator, which is /// what a migration needs: the same account shape can be re-owned by a /// Safe later without changing which validator is root. /// /// It is also `IHook` and reports `isModuleType(4) == true`, which is what /// keeps the owner's direct-call exit alive: `onlyEntryPointOrSelfOrRoot` /// (`Kernel.sol`:79-94) hands a non-EntryPoint caller to `preCheck`, and being /// the caller is the proof. A Safe can therefore drive the account with no /// bundler, no paymaster and no account ETH. /// /// DEPLOYED IMMUTABLY AND ON PURPOSE. Every other genesis-named contract here /// (AgentRegistry, AdapterInstaller) sits behind an ERC-1967 proxy so it can be /// fixed. This one does not. Whoever can replace a /// ROOT VALIDATOR's code can authorise any operation on every account that /// names it, which is a strictly larger power than the proxy admin holds /// anywhere else in this system — and PASSKEY_OWNER.md §5 already records /// `UPGRADER_ROLE` as an open custody question for exactly this reason. A plain /// CREATE2 deploy makes "who can change the rules that authorise spending from /// your account" answer "nobody". The cost is that a bug here is fixed by /// deploying a new validator, which moves every address not yet derived. /// /// THE ADDRESS IS IN THE SALT. `RootValidatorID()` is the first 21 bytes of /// `initData` and `KernelFactory` hashes `initData` into the CREATE2 salt, so /// adopting this validator changes the address of every account not yet /// derived. There is no migration for one already published — see /// PASSKEY_OWNER.md F4 and §9 item 0. /// /// UNAUDITED, and deliberately minimal. What it does NOT do, in the order it /// would matter: no ERC-7739 nested-signature defence on the ERC-1271 path /// (a Safe that owns two Kernel accounts answers the same digest for both); /// no owner rotation; no guard against the owner being a contract that later /// upgrades into something else. Proven by contracts/test/SafeRootValidator.t.sol. contract ERC1271RootValidator is IValidator, IHook { event OwnerRegistered(address indexed kernel, address indexed owner); error OwnerCannotBeZero(); error SenderIsNotOwner(address sender, address owner); mapping(address kernel => address owner) public ownerOf; /// THE RE-INSTALL GUARD IS LOAD-BEARING, and it is why this is not a plain /// assignment like the vendored ECDSAValidator's. /// /// Without it, installing this module as a NON-root validator on an account /// that already has it as ROOT overwrites the root owner with whoever the /// second install names — and Kernel's genesis runs installModule calls from /// initConfig, so that would be a silent total takeover at account creation. /// The audited ECDSAValidator deployed at 0x845ADb2C… carries this same /// guard (its bytecode contains the AlreadyInitialized selector) while the /// source vendored in contracts/lib does not; this follows the deployment. /// /// onUninstall clears the slot, so a deliberate re-owning still works — /// but only once this validator is no longer the account's ROOT. That /// condition was missing until 2026-09-10 and it was the whole guard: /// `uninstallModule(4, this, "")` cleared the slot, `installModule(4, /// this, attacker)` refilled it, and the account had a new owner in two /// unsigned calls. See RootBinding. function onInstall(bytes calldata data) external payable override { if (ownerOf[msg.sender] != address(0)) revert AlreadyInitialized(msg.sender); address owner = address(bytes20(data[0:20])); if (owner == address(0)) revert OwnerCannotBeZero(); ownerOf[msg.sender] = owner; emit OwnerRegistered(msg.sender, owner); } function onUninstall(bytes calldata) external payable override { if (ownerOf[msg.sender] == address(0)) revert NotInitialized(msg.sender); // A ROOT CANNOT BE DE-REGISTERED OUT FROM UNDER ITS OWN ACCOUNT. // Kernel guards `uninstallModule(1, …)` and does not guard // `uninstallModule(4, …)`, which reaches here just the same because // `isModuleType(4)` is true. Clearing the slot is what re-arms // `onInstall`. See RootBinding. RootBinding.requireNotRootOf(msg.sender, address(this)); delete ownerOf[msg.sender]; } function isModuleType(uint256 typeID) external pure override returns (bool) { return typeID == MODULE_TYPE_VALIDATOR || typeID == MODULE_TYPE_HOOK; } function isInitialized(address smartAccount) external view override returns (bool) { return ownerOf[smartAccount] != address(0); } /// The ERC-4337 path. `userOpHash` is handed over RAW — Kernel does not /// wrap it (Kernel.sol:237) — so the owner sees exactly what the bundler /// hashed, and a Safe applies its own SafeMessage wrapper on top. function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash) external payable override returns (uint256) { // Same `isRootOf` gate as `isValidSignatureWithSender`: rotation must // revoke ERC-4337 authority, not only the ERC-1271 read path. if (!RootBinding.isRootOf(msg.sender, address(this))) { return SIG_VALIDATION_FAILED_UINT; } address owner = ownerOf[msg.sender]; if (owner == address(0)) return SIG_VALIDATION_FAILED_UINT; if (SignatureCheckerLib.isValidSignatureNowCalldata(owner, userOpHash, userOp.signature)) { NoExtraValidator.reject(msg.sender, address(this), userOp.callData); return SIG_VALIDATION_SUCCESS_UINT; } return SIG_VALIDATION_FAILED_UINT; } /// The ERC-1271 read path. The `hash` arriving here is NOT the caller's — /// Kernel wraps it first: `_toWrappedHash(hash)` = EIP-712 over /// `Kernel(bytes32 hash)` in the ACCOUNT's domain (Kernel.sol:329). /// Anything signing for this account must reproduce that wrapper, and a /// Safe owner then wraps once more. Two domains, both mandatory. function isValidSignatureWithSender(address sender, bytes32 hash, bytes calldata sig) external view override returns (bytes4) { // NOT ROOT, NOT SIGNING — two holes, one answer, both in RootBinding. // // `sender == msg.sender` is Kernel's ENABLE MODE and nothing else: // `_checkEnableSig` asks the root validator with `address(this)` as the // sender, while `Kernel.isValidSignature` always passes the external // caller. Enable mode installs a validator through // `_enableValidationWithSig` with NO `installModule` calldata anywhere // — the path the ZeroDev SDK reaches for by default — so the calldata // filter never sees it. Refusing the enable digest is the only place // this can be stopped without forking Kernel. // // The `isRootOf` check answers the other one: `changeRootValidator` // leaves the previous root installed with a live `validationConfig`, // and `Kernel.isValidSignature` will route `0x01‖oldRoot` to it // forever. A validator that is no longer this account's root signs // nothing for it. if (sender == msg.sender) return ERC1271_INVALID; if (!RootBinding.isRootOf(msg.sender, address(this))) return ERC1271_INVALID; address owner = ownerOf[msg.sender]; if (owner == address(0)) return ERC1271_INVALID; if (SignatureCheckerLib.isValidSignatureNowCalldata(owner, hash, sig)) { return ERC1271_MAGICVALUE; } return ERC1271_INVALID; } /// The direct-call path. No signature is checked because none is needed: /// the owner contract is the caller, and reaching here means it already /// authorised the call under its own rules (a Safe threshold, for one). function preCheck(address msgSender, uint256, bytes calldata msgData) external payable override returns (bytes memory) { address owner = ownerOf[msg.sender]; if (msgSender != owner) revert SenderIsNotOwner(msgSender, owner); NoExtraValidator.reject(msg.sender, address(this), msgData); return hex""; } function postCheck(bytes calldata) external payable override {} }