fix(account): stop a hook being removed out from under its validator - #313
fix(account): stop a hook being removed out from under its validator#313emirongrr wants to merge 4 commits into
Conversation
Every built-in primary validator stores a policy-hook address per account and fails closed when that hook is not installed. Nothing connected those two facts: `_uninstallModule` had no dependency check, and `evictHookWithGuardians` removed a hook immediately. That left a terminal state. With the account's only hook evicted, its validator could no longer pass `validateUserOp` or `validateDirectExecution`; `setPolicyHook` needs a scheduled self-call that only a passing validator can reach; and guardian recovery installs validators but not hooks, so it could not repair the account either. The account was unusable and unrecoverable. It went unnoticed because ADR-0005's acceptance evidence for hook eviction uses `MockValidator`, which declares no policy hook, so the one configuration that makes eviction dangerous was never exercised. Validators can now declare the dependency through `ILoomPolicyBoundValidator`, and the account enforces it: - `_uninstallModule` refuses to remove a hook an installed validator depends on, which covers the scheduled-uninstall path as well as guardian eviction. - `evictHookWithGuardians` takes a `replacement`. When a validator depends on the hook a replacement is required; the account installs it, rebinds every dependent validator onto it, and only then removes the old hook, in that order. Eviction without a replacement stays available when nothing depends on the hook, so the escape hatch is not made harder than it needs to be. - Initialization asserts coherence once the module set is in place, so an account cannot be created with a validator pointing at a hook that is not installed. Rebinding is gated on the account reporting `isEvictingHook()`. Without that gate it would be an instant, untimelocked way to re-point a validator at a permissive hook, which `setPolicyHook`'s configuration delay exists to prevent. Two constraints shaped the design and are worth recording, because both rule out the obvious alternatives: A validator cannot check its own dependency at initialization. Module init runs inside the account's install loop, and during a constructor or proxy deployment the account has no code yet, so a callback into it returns nothing and reverts on decode. The check therefore lives in the account, which can call out to a deployed validator, and runs at the end of initialization rather than per install -- modules arrive as a set, so a validator may legitimately name a hook that appears later in the list. The dependency is probed with `try/catch`. A validator that does not implement the interface, or that reverts, reads as depending on nothing. That is deliberate: the alternative hands any module a veto over hook removal, including over the guardian escape hatch, which is the denial ADR-0005 exists to solve. The cost is that the guard only protects modules that declare honestly, which is recorded in the bug-class matrix. Rejected: letting a validator fall back to "no policy hook" when its hook is missing, which converts a liveness failure into a policy bypass; and blocking eviction outright, which would let a malicious hook make itself unremovable. `ValidatorBranchCoverage` and `P256Validator` each contained a test that removed a hook to reach the validator's fail-closed branch. That state is now unreachable through a real account, so those tests reach it through a stub account that reports the hook absent. The branch stays covered -- it still matters for a validator used with a non-Loom account or a future account version -- without requiring the account to permit an incoherent state.
…ath widened `evictHookWithGuardians` gained a `replacement` argument, and `docs/design/execution.md` still said the function "can only uninstall a hook - it cannot install one". That sentence was false as soon as the replacement path landed, in the design document a reader is most likely to check. The section now describes what the call actually does and names the residual directly: this is the one place the guardian threshold installs a module, and because `isLowRisk` on the policy hook is the only gate `validateDirectExecution` consults, the replacement the guardians name decides what direct execution is permitted. That is more than the "guardians can cancel, never act" line the rest of the design holds to. It is bounded by three things and they belong together: the same threshold can already replace every validator through `recoverConfiguration`, so the delta is timing rather than category — recovery is visible for three days, this is immediate; the call installs exactly the hook named in the signed digest and touches nothing else; and rebinding is reachable only during an eviction, since outside one it would be an untimelocked way to point a validator at a permissive hook. Decision 0005 carries the same statement, which its amendment had left implicit.
|
Follow-up commit |
…pendency # Conflicts: # .gas-snapshot # docs/security/wallet-bug-regression.md # src/LoomAccount.sol # tools/quality/run-critical-guard-mutations.mjs
Merging with main surfaced a real defect in this branch's guard. `P256Validator` gained a `P256RecoveryValidator` subclass, which inherits `policyHookFor`, so the coherence assertion added here started firing inside `recoverConfiguration` and reverted the whole recovery whenever the guardians' replacement validator named a hook that was not installed. Three tests that arrived with that feature failed. Blocking recovery is the wrong direction to fail. Recovery is the last-resort path: it runs on guardian-threshold authority through an installed recovery module and needs no working validator, so a revert leaves the validator the guardians are trying to replace in place. Installing one that fails closed is recoverable — another recovery repairs it — while a blocked recovery is not. Both recovery entry points now set a `_recovering` flag that skips the assertion, in the same shape as the existing `_initializing` flag. The exemption is bounded and pinned: the new test asserts the same validator and init data are still refused on the ordinary construction path, so the two cannot drift apart. Decision 0005 records the reasoning. Known blocker, not fixed here: with this branch `LoomAccount` runtime reaches 25,146 bytes against the 24,576 EIP-170 limit — 570 over, where main sits 910 under. `forge build --sizes` exits non-zero and both `verify.mjs` and CI run it, so this cannot merge as it stands. `evictHookWithGuardians` measures 1,131 bytes on its own, so the size has to come out of that function or move behind a linked library. Deliberately left failing rather than papered over.
|
Held: this cannot merge as it stands. With this branch Moving the logic to a library only helps if it is a deployed, link-time-bound library with Separately, one real defect was found and fixed while merging: the coherence check added here reverted The remaining audit PRs are being merged ahead of this one. |
Dependabot's #331 bumped only the `solc` npm dependency, which `npm run toolchain:check` refused: `foundry.toml`, both Certora `solc-select` steps, and the Kontrol `SOLC_BINARY` were still on 0.8.35, and a second compiler reachable from the repository produces bytecode that no gate and no deployment manifest ever measured. That gate exists to stop a partial bump, so the answer is to move everything or nothing. Everything moves here: 145 source `pragma` statements, `foundry.toml`, the npm dependency and lockfile, four `solc-select` invocations, the Kontrol binary name and its checksum, the assertions in `validate-ci-program.mjs` and `validate-kontrol-program.mjs` that pin those strings, and the four documents that state the version. The two Kontrol pins are derived, not copied. The compiler that produced these artifacts reports `0.8.36+commit.8a079791`, read back out of the build metadata, and the binary's SHA-256 is `c8d35afdddc3cd2743ee88b8f25e0fecd16e2bdd5f2120f37e52cd9cc45ae0e6`, computed by downloading it. The same download reproduces the existing 0.8.35 pin exactly, which is what makes the method trustworthy here: `list.json` publishes a different SHA-256 for 0.8.35 than the file it serves, so it cannot be used as the source for this field. Measured rather than assumed: - `LoomAccount` runtime stays at 23,733 bytes, leaving the EIP-170 margin unchanged at 843. The upgrade is size-neutral for the account, which matters because that margin is the binding constraint on #313. - Gas barely moves: 22 of 302 snapshot entries change, 15 up and 7 down, median +28, extremes -56 and +56. Codegen jitter, not a systematic shift. - 393 tests pass, 15 skipped; 16/16 critical guard mutants killed; coverage, lint, formatting, ABI freshness, documented constants, and the CI, Certora, Kontrol, and formal program-structure gates all pass. `validate-toolchain-pins.test.mjs` needed one real edit beyond the version strings: its disagreement case worked by passing the *other* version, so moving the fixture default alone would have left it asserting that two identical versions disagree. The intruder is now 0.8.35. Not included: any behavioural change. No file under `src/` changes except its pragma line.
Dependabot's #331 bumped only the `solc` npm dependency, which `npm run toolchain:check` refused: `foundry.toml`, both Certora `solc-select` steps, and the Kontrol `SOLC_BINARY` were still on 0.8.35, and a second compiler reachable from the repository produces bytecode that no gate and no deployment manifest ever measured. That gate exists to stop a partial bump, so the answer is to move everything or nothing. This is not a hand-written diff. It is the output of npm run toolchain:bump 0.8.36 npm install --package-lock-only which is the tool added in #337, used here for the first time. It rewrote 157 files - 145 pragmas, `foundry.toml`, the npm dependency, four `solc-select` invocations, the Kontrol binary name and its checksum, the validators that assert those strings, the prover setup script, and four documents - and reported nothing left over for a human to decide. The two Kontrol pins are derived rather than copied. The build identifier `0.8.36+commit.8a079791` comes from the published binary list, and the SHA-256 `c8d35afdddc3cd2743ee88b8f25e0fecd16e2bdd5f2120f37e52cd9cc45ae0e6` is computed from the binary the tool downloads. That distinction matters: `list.json` publishes a different SHA-256 for 0.8.35 than the file the same host serves, while downloading and hashing reproduces the checksum already pinned here. Measured rather than assumed: - `LoomAccount` runtime stays at 23,733 bytes, leaving the EIP-170 margin unchanged at 843. The upgrade is size-neutral for the account, which matters because that margin is the binding constraint on #313. - Gas barely moves: 22 of 302 snapshot entries change, 15 up and 7 down, median +28, extremes -56 and +56. No entry appears or disappears. Codegen jitter, not a systematic shift. - 393 tests pass, 15 skipped; 16/16 critical guard mutants killed; the full `verify:quick` program is green, including coverage, lint, formatting, ABI freshness, documented constants, and the CI, Certora, Kontrol, and formal program-structure gates. Not included: any behavioural change. No file under `src/` changes except its pragma line.
## Motivation Dependabot's #331 bumped only the `solc` npm dependency, which `npm run toolchain:check` refused: `foundry.toml`, both Certora `solc-select` steps, and the Kontrol `SOLC_BINARY` were still on 0.8.35. A second compiler reachable from the repository produces bytecode that no gate and no deployment manifest ever measured. That gate exists to stop a partial bump, so the answer is to move everything or nothing. ## Description This is not a hand-written diff. It is the output of ``` npm run toolchain:bump 0.8.36 npm install --package-lock-only ``` which is the tool added in #337, used here for the first time. It rewrote 157 files — 145 pragmas, `foundry.toml`, the npm dependency, four `solc-select` invocations, the Kontrol binary name and its checksum, the validators that assert those strings, the prover setup script, and four documents — and reported nothing left over for a human to decide. The two Kontrol pins are derived rather than copied. The build identifier `0.8.36+commit.8a079791` comes from the published binary list, and the SHA-256 `c8d35afdddc3cd2743ee88b8f25e0fecd16e2bdd5f2120f37e52cd9cc45ae0e6` is computed from the binary the tool downloads. That distinction matters: `list.json` publishes a **different** SHA-256 for 0.8.35 than the file the same host serves, while downloading and hashing reproduces the checksum already pinned here. ## Scope **Included** - Every compiler pin in the repository, plus the regenerated lockfile and gas snapshot **Not included** - Any behavioural change. No file under `src/` changes except its pragma line. ## Security impact The produced bytecode changes, so deployment evidence has to be regenerated before any deployment claim is made against it. The supply-chain property the bump protects is that exactly one compiler is reachable from the repository; `toolchain:check` enforces it and passes here. ## Verification - `LoomAccount` runtime stays at **23,733 bytes**, EIP-170 margin unchanged at **843**. Size-neutral for the account, which matters because that margin is the binding constraint on #313. - Gas barely moves: **22 of 302** snapshot entries change, 15 up and 7 down, median +28, extremes -56 and +56. No entry appears or disappears — codegen jitter, not a systematic shift. - `npm run verify:quick` — full program green: 393 contract tests pass, 15 skipped. - `npm run test:mutation:critical` — 16/16 killed. ## Residual risks Deployment manifests and published bytecode evidence predate this compiler and must be rebuilt before they are cited again. ## Review notes The diff is mechanical and large; the parts worth reading are `foundry.toml`, `package.json`, the two workflow files, and `tools/formal/setup-linux-provers.sh`. If those four agree, `toolchain:check` guarantees the other 150 do.
Motivation
Every built-in primary validator stores a policy-hook address per account and
fails closed when that hook is not installed. Nothing connected those two facts:
_uninstallModulehad no dependency check, andevictHookWithGuardiansremoved ahook immediately.
That left a terminal state. With the account's only hook evicted, its validator
could no longer pass
validateUserOporvalidateDirectExecution;setPolicyHookneeds a scheduled self-call that only a passing validator canreach; and guardian recovery installs validators but not hooks, so it could not
repair the account either. The account was unusable and unrecoverable.
It went unnoticed because ADR-0005's acceptance evidence for hook eviction uses
MockValidator, which declares no policy hook, so the one configuration thatmakes eviction dangerous was never exercised.
Validators can now declare the dependency through
ILoomPolicyBoundValidator,and the account enforces it:
_uninstallModulerefuses to remove a hook an installed validator depends on,which covers the scheduled-uninstall path as well as guardian eviction.
evictHookWithGuardianstakes areplacement. When a validator depends on thehook a replacement is required; the account installs it, rebinds every dependent
validator onto it, and only then removes the old hook, in that order. Eviction
without a replacement stays available when nothing depends on the hook, so the
escape hatch is not made harder than it needs to be.
cannot be created with a validator pointing at a hook that is not installed.
Rebinding is gated on the account reporting
isEvictingHook(). Without that gateit would be an instant, untimelocked way to re-point a validator at a permissive
hook, which
setPolicyHook's configuration delay exists to prevent.Two constraints shaped the design and are worth recording, because both rule out
the obvious alternatives:
A validator cannot check its own dependency at initialization. Module init runs
inside the account's install loop, and during a constructor or proxy deployment
the account has no code yet, so a callback into it returns nothing and reverts on
decode. The check therefore lives in the account, which can call out to a
deployed validator, and runs at the end of initialization rather than per install
-- modules arrive as a set, so a validator may legitimately name a hook that
appears later in the list.
The dependency is probed with
try/catch. A validator that does not implement theinterface, or that reverts, reads as depending on nothing. That is deliberate: the
alternative hands any module a veto over hook removal, including over the guardian
escape hatch, which is the denial ADR-0005 exists to solve. The cost is that the
guard only protects modules that declare honestly, which is recorded in the
bug-class matrix.
Rejected: letting a validator fall back to "no policy hook" when its hook is
missing, which converts a liveness failure into a policy bypass; and blocking
eviction outright, which would let a malicious hook make itself unremovable.
ValidatorBranchCoverageandP256Validatoreach contained a test that removed ahook to reach the validator's fail-closed branch. That state is now unreachable
through a real account, so those tests reach it through a stub account that
reports the hook absent. The branch stays covered -- it still matters for a
validator used with a non-Loom account or a future account version -- without
requiring the account to permit an incoherent state.
Scope
Included
Not included
Security impact
Authority and availability. A hook could be removed out from under the validator that depends on it.
Verification
forge test,npm run test:mutation:critical.Residual risks
Three pre-existing tests reach the fail-closed branch through a stub account rather than by uninstalling a hook; the stub is new, the assertions are unchanged.
Checklist
npm run verifypasses (full program not run locally;forge testand the finding-specific gates are green)