M-04: Authority-Gated Setters Are Permissionless When AuthControlled Is Paired with AuthSingleSigAcl
- Dominant language
- Rust
- Stars
- 132
- Forks
- 167
- Avg merge
- 1d 23h
- Merged PRs (30d)
- 110
Description
The `Authority` component selects a single account-wide gating mode that every authority-gated setter consults, including the `TokenPolicyManager` procedures `set_mint_policy`, `set_burn_policy`, `set_send_policy`, and `set_receive_policy`, as well as the fungible token metadata setters and the pause controls. Each such setter calls `authority::assert_authorized` before writing to storage. Under `Authority::AuthControlled`, `assert_authorized` [is a no-op](https://github.com/0xMiden/protocol/blob/2ef8056323df258917d119383a7cdd49b064d88a/crates/miden-standards/asm/standards/access/authority.masm#L52-L56), so the account's auth component becomes the sole gate for every authority-gated setter. The `Authority::AuthControlled` documentation states this invariant explicitly: the auth component must authenticate every setter root, otherwise the setters become permissionless.
When the chosen auth component is `AuthSingleSigAcl`, this invariant does not hold under the component's default configuration. The `auth_tx_acl` procedure requires a signature only when a registered trigger procedure was called, when output notes were created and `allow_unauthorized_output_notes` is `false`, or when input notes were consumed and `allow_unauthorized_input_notes` is `false`. When none of these conditions hold, control reaches the `else` [branch](https://github.com/0xMiden/protocol/blob/2ef8056323df258917d119383a7cdd49b064d88a/crates/miden-standards/asm/account_components/auth/singlesig_acl.masm#L155-L181), which increments the nonce and finalizes the transaction without verifying any signature. The default configuration produced by `AuthSingleSigAclConfig::new` registers an empty trigger list, so no setter root is tracked, and a transaction that consumes no input notes and creates no output notes satisfies none of the signature conditions regardless of the `allow_unauthorized_*` flags. The [transaction-is-empty check in the epilogue](https://github.com/0xMiden/protocol/blob/2ef8056323df258917d119383a7cdd49b064d88a/crates/miden-protocol/asm/kernels/transaction/lib/epilogue.masm#L461-L470) does not prevent this, because it rejects a transaction only when the account delta is empty and there are no input notes, and a policy write produces a non-empty delta.
As a result, an unauthorized party holding no key can rewrite the policy of any account that installs `Authority::AuthControlled`, `AuthSingleSigAcl`, and an authority-gated component such as `TokenPolicyManager`:
1. The attacker constructs a transaction with no input notes and no output notes whose script calls `set_mint_policy`.
2. `assert_authorized` is a no-op under `AuthControlled`.
3. `auth_tx_acl` finds no triggered procedure and no note usage, takes the `else` branch, and increments the nonce without a signature.
4. The policy write produces a non-empty delta, the epilogue accepts the transaction, and the attacker's policy persists.
This permits any party to overwrite the mint, burn, send, and receive policies, the maximum supply, the metadata, and the pause state of an affected account. Because the unsafe behavior is present in the default `AuthSingleSigAcl` configuration rather than being gated behind a relaxed flag, an integrator that pairs these standard components without registering every gated setter as a trigger procedure ships an account whose policies are publicly writable. The remaining authority modes do not share this exposure, since under `OwnerControlled` and `RbacControlled` the call to `assert_authorized` reverts for an unauthorized sender and aborts the transaction before any write occurs. The purpose of `AuthSingleSigAcl`, which is to permit selected operations without a signature, is in direct tension with the `AuthControlled` premise that the auth component gates every setter.
Consider enforcing this invariant at account construction rather than relying on documentation. When `AuthSingleSigAcl` is installed alongside `Authority::AuthControlled` and an authority-gated component, account construction could require that every authority-gated setter root is present in `auth_trigger_procedures` and fail otherwise. At a minimum, consider documenting on `Authority::AuthControlled` that pairing it with a permissive auth component leaves authority-gated setters reachable without a signature even under the most restrictive configuration, and enumerating the setter roots an integrator must register as trigger procedures.
The issue are solved partly:
- `AuthSingleSigAcl` semantics are changed in this PR: https://github.com/0xMiden/protocol/pull/3065
- `all_authority_gated_setter_roots` are removed in this PR: https://github.com/0xMiden/protocol/pull/3180
**What's left is the following:**
The new logic in `AuthSingleSigAcl` gates only kernel-tracked account procedures. So a keyless transaction that creates an asset-less output note (bearing the account as sender) or consumes an input note whose script touches no account-restricted API now sets `auth_required = 0`, bumps no nonce, and verifies no signature. Consequences:
- (a) Anyone can make an `AuthSingleSigAcl` account emit notes carrying that account's ID as sender — this extends the L-05 sender-forgery surface from `no_auth` accounts to `AuthSingleSigAcl` accounts, by default.
- (b) A fee-griefing vector (victim pays the tx fee). Currently moot because M-05/#3108 removed kernel fee deduction, but it re-applies the moment fees are reintroduced.
There was a comment in the discussion:
> Creating an output note is not dangerous in and of itself. What is dangerous is adding assets from the account to that note. This requires calling `native_account::remove_asset`, which would generally not be exempt from signatures here, and so I don't think we need any note check.
However, there are legitimate scenarios that do not involve asset movements. For example, an attacker could steal ownership of an account by creating a note that calls `transfer_ownership` in `ownable2step`.
Contributor guide
Assessment
This issue has not been assessed yet.