fix(security): refuse builtin ERA/dBTC issuance at the accepting transition - #721
Merged
Merged
Conversation
…sition Any wallet user could mint unlimited ERA or dBTC. Four layers each declined to authorize it: handle_token_mint signs its OWN authorization and stamps authorized_by with the caller's device id; resolve_token_for_value_op returns the builtin commit; ERA's preloaded policy carries zero conditions and zero roles so enforce_policy iterates nothing and returns allowed while dBTC has no policy and takes the builtin escape hatch; and advance never calls enforce_operation_authorization, its only authorization call being gated to the three DLV ops. validate_conservation checks only that the single credit delta matches the amount and asset the same caller signed. Nothing established a RIGHT to issue. ERA is a tradeable AMM leg, so minted-from-air ERA buys real assets out of a vault, and dBTC is meant to be Bitcoin-backed. The gate is at DeviceState::advance, not on the route: a route guard binds only the callers that pass through it, so any future route or direct advance caller would silently reopen the hole. It is keyed on policy_commit, which is what validate_conservation binds the credit delta to, what the balances map is keyed by, and what the compat projection resolves a ticker FROM — not on the token_id string, since a builtin ticker with a non-builtin commit credits that non-builtin asset and can never project as ERA, so a string check would refuse honest issuance while closing nothing. Proven by mutation, not by a green suite: delete the block and the test fails because the advance SUCCEEDS, with the balance witness carrying 18446744073709551615 ERA; restore it and the mint is refused. SupplyCap is deliberately not the basis — it reads circulating_le from caller-supplied enforcement context and no canonical producer authenticates that number. faucet.claim was the same defect: it minted builtin ERA on a caller-supplied device_id plus a local cooldown. It now refuses explicitly and check_nearby reports unavailable; the minting body is DELETED rather than left unreachable, because a dead path that still knows how to mint is what a later edit resurrects. No exemption, no dev flag, no faucet key — the accepting gate stays the only control. Consequence: device funding needs an authenticated issuance predicate before the two-phone rig can fund fresh devices. Two fixtures funded heads by minting builtin ERA and now use DeviceState::restore, the shape a reloaded device actually has. In the projection tests ERA is load-bearing (the projection resolves tickers from the builtin commit, so a synthetic asset would project as empty and assert nothing); in the state-machine test it was incidental. dsm 1658/0; dsm_sdk 1772/0; make lint green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
Any wallet user could mint unlimited ERA or dBTC via the production
token.mintroute. Verified link by link:handle_token_mint(token_routes.rs:1871) checks onlyamount > 0, then signs its own authorization with the caller's device key and setsauthorized_by: dev_id.resolve_token_for_value_opreturns the builtin policy commit for ERA/dBTC.create_root_token_policy) sets only name/description/metadata — zero conditions, zero roles — soenforce_policyiterates nothing and returnsallowed. dBTC has no registered policy and takes the builtin escape hatch (policy/mod.rs:194-205).DeviceState::advancenever callsenforce_operation_authorization; its only authorization call is gated toDlvSettle | DlvOwnerApply | DlvClose.validate_conservationchecks only that the single credit delta matches the amount and asset the same caller signed. Nothing anywhere established a right to issue.ERA is a tradeable AMM leg, so minted-from-air ERA buys real assets out of a vault; dBTC is meant to be Bitcoin-backed. The bilateral path does not check sender balances either, so minted units also spend to counterparties.
The fix
At
DeviceState::advance, not on the route. A route guard binds only the callers that pass through it — any future route or directadvancecaller would silently reopen the hole.Keyed on
policy_commit, not the ticker. That is whatvalidate_conservationbinds the credit delta to, what the balances map is keyed by, and what the compat projection resolves a ticker from. A mint carrying the string"ERA"with a non-builtin commit credits that non-builtin asset and can never project as ERA, so rejecting on the string would refuse honest issuance while closing nothing. A second test pins that narrowness.SupplyCapis deliberately not the basis. It readscirculating_lefrom caller-supplied enforcement context, and no canonical producer authenticates that number.Proven by mutation, not by a green suite
A green suite around a gate proves nothing about whether the gate is load-bearing. Both tests assert on the specific message rather than
is_err(), so an unrelated error cannot credit the guard.The faucet was the same defect
faucet.claimminted builtin ERA on a caller-supplieddevice_idplus a local cooldown — no verifiable right to issue. It now returns an explicit refusal andcheck_nearbyreports unavailable. The minting body is deleted, not left unreachable: a dead path that still knows how to mint is what a later edit resurrects. No exemption, no dev flag, no faucet key — the accepting gate stays the only control.Consequence: device funding needs an authenticated issuance predicate before the two-phone rig can fund fresh devices.
rig_dlv_market.pydrives the UI and does not call the faucet API, so phones already holding legitimate ERA can still run the DLV flow. Authenticated testnet issuance is follow-up work and can later become anAuthorizedIssuancesource.Fixtures
Two fixtures funded heads by minting builtin ERA and now use
DeviceState::restore— the shape a reloaded device actually has. Worth noting the asymmetry: in the projection tests ERA is genuinely load-bearing (the projection resolves tickers from the builtin commit, so a synthetic asset would project as empty and the assertions would test nothing); in the state-machine test the asset was incidental. Each broken fixture marked a place the codebase had been relying on the unauthorized capability.Verification
dsmlib: 1658 passed / 0 faileddsm_sdklib: 1772 passed / 0 failed (7 ignored)make lintfrom the repo root: passed