fix(security,#437): stop asset_admin seizing an asset's oracle_authority - #438
fix(security,#437): stop asset_admin seizing an asset's oracle_authority#4380x-SquidSol wants to merge 1 commit into
Conversation
…le_authority `handle_update_asset_authority` skips the current holder's consent check whenever `admin_signed`. dcccrypto#430 scoped the carve-out to the two insurance legs, so `ASSET_AUTH_ORACLE` still took the bypass: `asset_admin` could take an asset's `oracle_authority` from a holder that never signed, then reach the asset's price surface through `ConfigureAuthMark` (which switches oracle_mode, so the asset need not already be in auth-mark) and `PushAuthMark`. Measured on a live market: mark 100 -> 999_000_000. Same root cause as dcccrypto#414 and dcccrypto#416/dcccrypto#417, through the fourth leg. This also restores the model the repo already documents in README: "Non-burn transfers require both the current authority and the new key to sign." Adds ASSET_AUTH_ORACLE to the existing exclusion list. Deliberately NOT the blanket form (`admin_bypass_permitted = current_value == [0u8; 32]`): that reintroduces the brick review of dcccrypto#415 caught, because `backing_bucket_authority` is bound to the LP vault registry PDA, a PDA cannot sign, and the field would be welded permanently. Verified -- under the blanket form, backing_authority_rotation_is_allowed_when_no_lp_vault_registry_exists fails with Custom(8). Operational cost, stated plainly: a lost or compromised oracle key can now be contained (ASSET_ACTION_SHUTDOWN, available to marketauth or asset_admin) but not replaced, and for asset 0 not replaced at all, since ASSET_ACTION_RETIRE rejects index 0. That is the same property the already-shipped insurance legs have, so this extends an accepted trade-off rather than introducing a new one. A follow-up worth considering is giving RestartAssetOracle an oracle_authority parameter -- it is already asset_admin-gated and only reachable from RECOVERY, so it would restore eviction without reopening the live-market seizure closed here. TDD: both tests written first and watched to FAIL on unfixed main; the two existing insurance tests keep passing throughout, which is what makes the differential meaningful. v16_wrapper 223 passed / 19 failed against a baseline of 221 / 19 -- the failing set matches tests/KNOWN_FAILING.txt exactly. Fork suites all green: lp_vault deposit 10, create 5, redeem 27, admin 8, state 21, authority canary 5, adversarial 14. Refs dcccrypto#437 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe asset authority update path now requires holder consent for occupied oracle authority. New regression tests cover unauthorized authority takeover, oracle mark configuration, and oracle mark updates. ChangesAuthority consent enforcement
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The change narrows administrative authority replacement so oracle ownership still requires the current holder’s consent while preserving the existing recovery exception. No actionable merge-blocking risk remains beyond normal checks and review. Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Linked Issues checkExplanation The PR prevents unconsented seizure of ASSET_AUTH_ORACLE and blocks related oracle mark manipulation. It does not implement the linked issue's required ASSET_AUTH_BACKING_BUCKET protection or its regression test, and it does not cover future authority kinds. Full details: Docstring CoverageExplanation No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0 files. (2 skipped: 2 too large.)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Warning Some tools did not complete. Review the errors below. 🔧 Clippy (1.97.1)Clippy execution failed Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Thanks @0x-SquidSol — the finding was right and it shipped, just via a broader guard than this patch. #437/#439 are fixed and deployed (wrapper Why the merged fix is differentThis PR adds let admin_bypass_permitted = !matches!(
kind,
ASSET_AUTH_INSURANCE | ASSET_AUTH_INSURANCE_OPERATOR | ASSET_AUTH_ORACLE
) || current_value == [0u8; 32];That closes the oracle leg, but leaves the bypass open for The merged fix inverts it into an allowlist, so the bypass is denied for every kind unless there is genuinely no holder to defend: let admin_bypass_permitted = admin_signed
&& (current_value == [0u8; 32] || { /* LP-vault registry PDA, BACKING_BUCKET only */ });The registry-PDA carve-out exists because LP-vault custody (#424) needs it and the holder there is a PDA that cannot sign. Your analysis was also ahead of us in one placeThe note that One thing worth passing onUpstream Equivalent coverage to your differential test now lives in our authorization gate, which sweeps every authority kind rather than the two the old guard named. |
Fixes #437.
What was wrong
handle_update_asset_authorityskipsexpect_live_authority(¤t_value, current.key)— the current holder's consent check — wheneveradmin_signed. #430 scoped the carve-out to the two insurance legs:There are five authority kinds.
ASSET_AUTH_ORACLEstill took the bypass, soasset_admincould take an asset'soracle_authorityfrom a holder that never signed — and that role gates the asset's price surface:ConfigureAuthMark(:13809),ConfigureEwmaMark(:13705),ConfigureHybridOracle(:13583),PushEwmaMark(:13914),PushAuthMark(:13990).Because
ConfigureAuthMarkswitchesoracle_modetoAUTH_MARK(:13812), the attack is not limited to assets already configured that way. The issue measures the full chain on a live market: mark100 -> 999_000_000.This also restores the model the repo already documents (
README.md:453):The pre-fix oracle leg did not honour that.
The fix
One word:
ASSET_AUTH_ORACLEadded to the existing exclusion list.Why not the blanket form
The tempting version is:
Do not use it. It reintroduces the unrecoverable brick that review of #415 caught.
handle_update_asset_authorityenforcesexpect_signer(current),handle_create_lp_vaultbindsbacking_bucket_authority = registry_pda(:14561), and a PDA cannot sign — so the field would become permanently unrotatable once the vault is gone. #424's guard (:12418) is narrower on purpose: it refuses only while the registry is initialized, and that gap is the recovery path.Measured, under the blanket form:
That is the regression test added alongside #424 for exactly this brick. I originally suggested the blanket form in #437 and retracted it there once measured.
A note on the comment
The
current_value == [0u8; 32]escape is effectively dead code for the oracle leg — every activation path writes a non-zerooracle_authority, and the zero-burn at:12378is rejected for every kind exceptASSET_AUTH_ADMIN. It is kept for uniformity with the insurance legs, and the code comment says so rather than implying the oracle role can be vacant.Operational cost — stated plainly
This is the real trade-off and it should be a deliberate choice, not a surprise:
ASSET_ACTION_SHUTDOWN(:12912), available tomarketauthorasset_admin, moves the asset to RECOVERY, after whichrequire_asset_mark_pushable_viewrejects further pushes.RestartAssetOraclepreservesoracle_authority(:12658), so bringing the asset back hands the key to the same holder. Full replacement needsASSET_ACTION_RETIREthen re-ACTIVATE.RETIRErejectsasset_index == 0, so asset 0's oracle key is unreplaceable once delegated.Two things make me think this is still the right trade:
ASSET_AUTH_INSURANCEandASSET_AUTH_INSURANCE_OPERATORexactly this treatment, andRETIRE'sasset_index == 0rejection applies identically. This extends an accepted trade-off rather than introducing a new class of risk.asset_admin, so anyone delegating pricing to a third-party feed provider had no protection at all — the slot owner could take the oracle and print a mark on a live market.No routine operation breaks: rotation of a hot pusher key is initiated by the outgoing key with the incoming key co-signing,
asset_adminis not a party, and activation-time provisioning namesoracle_authorityinline and is untouched.Suggested follow-up (not in this PR): give
RestartAssetOracleanoracle_authorityparameter. It is alreadyasset_admin-gated (:12641) and only reachable from RECOVERY — i.e. after the asset is already frozen — so installing a fresh key there would restore eviction without reopening the live-market seizure this PR closes. Happy to open it separately.Scope
Not in this PR:
ASSET_AUTH_BACKING_BUCKETis still exposed when the holder is an ordinary signable key rather than the registry PDA — #424's guard only covers the PDA case, and that field gatesWithdrawBackingBucket(:10221), which moves tokens. It is the same class and wants a carve-out shaped likecurrent_value == registry_pdaso the PDA recovery path survives. I am filing it separately rather than widening a security fix past the issue it references.Also worth recording, since it qualifies the issue's framing:
oracle_authorityis not the only price leverasset_adminholds.ASSET_ACTION_SHUTDOWNfollowed byRestartAssetOracle(initial_price)rebuilds the profile at an arbitrary price. That path is far weaker — the engine'srequire_empty_asset_lifecycle_staterequiresk_long == 0 && k_short == 0, so the asset must have no open interest, meaning there are no positions to misprice. The oracle leg closed here is the one that works on a live asset with open positions.Testing
Two regression tests, both written first and watched to fail on unfixed
main, while the two existing insurance tests pass throughout — which is what makes the differential meaningful:With the fix, all four pass. The first test is a differential over
kindwith identical accounts, so a future carve-out that re-opens one leg shows up immediately.v16_wrappermain: 221 / 19)v16_fork_lp_vault_depositv16_fork_lp_vault_createv16_fork_lp_vault_redeemv16_fork_lp_vault_adminv16_fork_lp_vault_state_testsv16_authority_binding_canaryv16_fork_adversarialThe
v16_wrapperfailing set matchestests/KNOWN_FAILING.txtexactly — 19 documented, 19 actual, none undocumented and none newly passing — which is that file's stated criterion for green. The delta over baseline is exactly the two tests added here.BPF built with
cargo build-sbf --no-default-features --tools-version v1.52before running the fork suites; without it those suites fail spuriously on a stale artifact.All
src/v16_program.rsline references above are againstmain(a1a8168), not this branch — the change adds 11 lines at:12388, so anything below that shifts here.Summary by CodeRabbit
Bug Fixes
Tests