// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {WebAuthn} from "solady/utils/WebAuthn.sol"; import {P256} from "solady/utils/P256.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 that authenticates a PASSKEY: a secp256r1 /// credential in a platform authenticator, verified through the WebAuthn /// envelope the browser actually produces. Topology B of PASSKEY_OWNER.md §4. /// /// The owner of record is the public key itself — two words in initData rather /// than an address — so there is no owner CONTRACT to deploy per chain, and /// none of the F3/S1/S3/S4/S5/S6 family in that report applies. /// /// Two departures from ZeroDev's deployed `WebAuthnValidator`, both deliberate /// and both measured in the probe suite: /// /// 1. `isModuleType(4)` is TRUE here and FALSE there (live eth_call on three /// chains, PASSKEY_OWNER.md §10). That single boolean is the report's /// headline cost — under a hook-less root, `onlyEntryPointOrSelfOrRoot` /// takes `revert InvalidCaller()` for every caller that is not the /// EntryPoint, so the owner has no direct-call exit at all and every /// withdrawal needs a bundler. Being a hook restores the exit, and /// `exitCaller` below is what it is granted to. /// 2. `challengeIndex` and `typeIndex` are both per-signature (solady's /// shape). ZeroDev hardcodes `CHALLENGE_LOCATION = 23`, which assumes /// `clientDataJSON` begins exactly `{"type":"webauthn.get",` — true of /// the browsers tested, not guaranteed of React Native or a security key. /// /// `exitCaller` deserves its own warning. It is a plain address that may drive /// the account with NO passkey signature, on the same authority the Safe has /// in the ERC-1271 prototype: being the caller is the proof. It is a second /// full-authority key, which is exactly what S1 of the report objects to in /// the PasskeyWallet design. Set it to zero and the exit is bundler-only; /// set it and someone holds it. That is the trade, and it is a custody /// decision, not an engineering one. /// /// What its walk refuses is RECONFIGURING THE ACCOUNT and rewriting the /// registry/installer/curation/adapter policy surface (setOpsRoot, armOperation, /// allowAdapter, activateOnly, refuseAdapter, setCap(s), setLtvCeiling, appoint, /// setRevokeRoot, resetAuthority, pinMarket, migrateToOwner) at any execute /// depth — otherwise the exit caller can zero the ops root and drop a v2 /// account back to permissionless v1 activate, or raise caps / pin markets. /// /// Deployed immutably, and the address is in the salt — see the header of /// ERC1271RootValidator.sol for both, which apply identically here. /// /// UNAUDITED. No ERC-7739 defence, no key rotation, no second credential, no /// counter checks. Proven by contracts/test/PasskeyRootValidator.t.sol. solady's `verify` does not check the WebAuthn origin or the /// rpIdHash either — it relies on the authenticator for RP binding. contract WebAuthnRootValidator is IValidator, IHook { /// `changeRootValidator(bytes21,address,bytes,bytes)`. Refused on the exit /// caller's path — see `preCheck`, H-10. bytes4 internal constant CHANGE_ROOT = 0x52141cd9; struct PublicKey { bytes32 x; bytes32 y; // Optional. Zero means the account has no direct-call exit and every // owner action must go through a bundler. address exitCaller; /// WHO THE ACCOUNT IS FOR — H-10 of the round-2 audit, and the field /// whose absence made `exitCaller` do two jobs. /// /// `RootOwner.ownerFrom` returned `passkeyOf` word 2 — the exit caller — /// verbatim, so `BaseAdapter.ownerOf` answered with it, /// `OwnerTransferAdapter` paid "return value to the owner" TO IT, and /// `AccountKycForwarder` minted the account ITS community credential and /// took ITS signature as owner consent. The passkey holder, who is the /// actual beneficial owner, was neither KYC-checked nor asked. /// /// And the safe configuration was unusable: with `exitCaller == 0`, /// `ownerFrom` returned zero, `ownerOf` reverted `CannotEstablishOwner`, /// and EVERY adapter operation reverted — so `RootOwner`'s stated fix /// ("three of four validators could not use any adapter at all") was /// unachieved for WebAuthn in both configurations. /// /// Separate now. An account may name a beneficiary and no exit caller, /// which is the configuration that should always have worked. /// `RootOwner` reads THIS word and falls back to zero — never to /// `exitCaller`. address owner; } event PasskeyRegistered( address indexed kernel, bytes32 x, bytes32 y, address exitCaller, address owner ); error PublicKeyCannotBeZero(); /// `initData` that is not 64, 84 or 104 bytes. See `onInstall`. error BadInitDataLength(uint256 length); /// The exit caller reaching for something an EXIT does not need. See /// `preCheck` — H-10. error NotAnExit(bytes4 selector); error NoDirectCallExit(address sender); error SenderIsNotExitCaller(address sender, address exitCaller); /// P-256 verification is not reachable on this chain, so every signature /// this validator ever checks would evaluate false. error P256Unavailable(); mapping(address kernel => PublicKey) public passkeyOf; /// initData is `x ‖ y` (64 bytes), optionally followed by a 20-byte /// `exitCaller`. Packed rather than abi-encoded because these bytes land /// in the CREATE2 salt: every byte is an account address input, and a /// length-prefixed encoding would put padding in the address derivation. /// The re-install guard is load-bearing for the same reason it is in /// ERC1271RootValidator — see the comment there. A second install would /// replace the account's passkey with whatever key the installer names. function onInstall(bytes calldata data) external payable override { PublicKey memory existing = passkeyOf[msg.sender]; if (existing.x != bytes32(0) || existing.y != bytes32(0)) { revert AlreadyInitialized(msg.sender); } // AN ACCOUNT MUST NOT BE CREATABLE FROZEN (M-11). // // `P256.verifySignature` consults the fallback verifier only when // `extcodesize(CANARY) == 0`, and a staticcall to a CODELESS verifier // returns zero-length rather than reverting — so `verify` returns FALSE // and does not revert. On a chain with neither the RIP-7212 precompile // nor the verifier deployed, every signature this validator is ever // shown evaluates false, INDISTINGUISHABLY from a wrong one: the // account silently and permanently freezes, and the owner's only clue // is that a correct passkey does not work. The repo's own tests work // around it with `vm.etch(P256.CANARY, "")`. // // Asked at install, which is the last moment anybody can be told. The // signature paths deliberately do not revert — a revert inside // `validateUserOp` is `FailedOpWithRevert(..., "AA23 reverted", ...)`, // a bundler rejection rather than a clean SIG_VALIDATION_FAILED — so // this is the only place the answer can be loud. if (!P256.hasPrecompileOrVerifier()) revert P256Unavailable(); // EXACT LENGTHS, AND THE REASON IS A PRIOR FINDING THAT WENT UNFIXED // TWICE: `exitCaller` was silently dropped for 64-83 byte `initData`. // One byte short and the account has no direct-call exit, permanently, // with the bad blob baked into its CREATE2 address. There is no reading // of a 71-byte payload that is anything but a mistake, so it is refused // rather than rounded down. // // 64 — passkey only: no exit, no beneficiary // 84 — + exitCaller // 104 — + owner (the beneficiary; see PublicKey.owner, H-10) if (data.length != 64 && data.length != 84 && data.length != 104) { revert BadInitDataLength(data.length); } bytes32 x = bytes32(data[0:32]); bytes32 y = bytes32(data[32:64]); // `&&` WAS `||`, AND THAT IS THE OTHER PRIOR FINDING. A half-zero key — // `onInstall(abi.encodePacked(bytes32(0), y))` — passed, `_verify` // repeated the same mistake, and the account froze permanently with the // bad key inside its CREATE2 address. The neighbouring guards at the // public-key getter and in `_verify` use `||` correctly. if (x == bytes32(0) || y == bytes32(0)) revert PublicKeyCannotBeZero(); address exitCaller = data.length >= 84 ? address(bytes20(data[64:84])) : address(0); address owner = data.length == 104 ? address(bytes20(data[84:104])) : address(0); passkeyOf[msg.sender] = PublicKey(x, y, exitCaller, owner); emit PasskeyRegistered(msg.sender, x, y, exitCaller, owner); } function onUninstall(bytes calldata) external payable override { PublicKey memory k = passkeyOf[msg.sender]; if (k.x == bytes32(0) && k.y == bytes32(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 passkeyOf[msg.sender]; } /// Can this chain verify a P-256 signature at all? /// /// Public so an operator can ask before deriving an account, rather than /// finding out from a passkey that stopped working. See `onInstall`. function p256Available() external view returns (bool) { return P256.hasPrecompileOrVerifier(); } 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) { PublicKey memory k = passkeyOf[smartAccount]; return k.x != bytes32(0) || k.y != bytes32(0); } /// The passkey never signs `userOpHash`. It signs /// `sha256(authenticatorData ‖ sha256(clientDataJSON))`, and `userOpHash` /// appears only inside that JSON, base64url-encoded without padding as the /// value of "challenge" — 43 characters for a 32-byte hash. All of that is /// reconstructed on-chain, per signature, by `WebAuthn.verify`. 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; } if (_verify(userOpHash, userOp.signature)) { NoExtraValidator.reject(msg.sender, address(this), userOp.callData); return SIG_VALIDATION_SUCCESS_UINT; } return SIG_VALIDATION_FAILED_UINT; } /// `hash` here is Kernel's wrapper, not the caller's digest — see the note /// on the same function in ERC1271RootValidator.sol. Under a passkey there /// is no second wrapper on top: the key signs the WebAuthn envelope over /// whatever bytes it is handed, so the count stops at two. 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; return _verify(hash, sig) ? ERC1271_MAGICVALUE : ERC1271_INVALID; } function preCheck(address msgSender, uint256, bytes calldata msgData) external payable override returns (bytes memory) { address exitCaller = passkeyOf[msg.sender].exitCaller; if (exitCaller == address(0)) revert NoDirectCallExit(msgSender); if (msgSender != exitCaller) revert SenderIsNotExitCaller(msgSender, exitCaller); // AN EXIT, NOT AN OWNER — H-10 of the round-2 audit. // // `NoExtraValidator.reject` deliberately ALLOWS `changeRootValidator`, // because on the owner's own root path it is the escape hatch the whole // design rests on. On this path it is the opposite: the exit caller // called `changeRootValidator` directly — no bundler, no passkey, one // transaction — and the account became an ordinary ECDSA account owned // by whoever held this address. `passkeyOf` survived and was never // consulted again. // // The header calls this "a second full-authority key", and that was the // understatement: it was a full ROOT-REPLACEMENT key. It is an exit now. // Moving value out is what an exit is for; deciding who owns the // account afterwards is not. if (msgData.length >= 4) { bytes4 sel = bytes4(msgData[0:4]); if (sel == CHANGE_ROOT) revert NotAnExit(sel); } // Nested `execute(self, changeRootValidator)` is the same eviction: // Kernel skips this hook on the inner self-call. Walk the payload. NoExtraValidator.rejectExit(msg.sender, address(this), msgData); return hex""; } function postCheck(bytes calldata) external payable override {} /// Non-reverting by construction: `tryDecodeAuth` returns an empty struct /// on a malformed blob and `verify` returns false on an empty /// `clientDataJSON`. That matters at the EntryPoint — a revert inside /// validation is `FailedOpWithRevert(..., "AA23 reverted", ...)`, which is /// a bundler rejection rather than a clean SIG_VALIDATION_FAILED. function _verify(bytes32 challenge, bytes calldata sig) internal view returns (bool) { PublicKey memory k = passkeyOf[msg.sender]; if (k.x == bytes32(0) && k.y == bytes32(0)) return false; return WebAuthn.verify(abi.encode(challenge), true, WebAuthn.tryDecodeAuth(sig), k.x, k.y); } }