From 6a6610c7a32d4bb20664f79fdaaaa90c1e7c2904 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:11:29 -0400 Subject: [PATCH] fix(security): refuse builtin ERA/dBTC issuance at the accepting transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../dsm/src/core/state_machine/mod.rs | 69 ++++----- .../dsm/src/types/device_state.rs | 138 ++++++++++++++++++ .../dsm_sdk/src/handlers/faucet_routes.rs | 85 ++++------- .../storage/client_db/projection_repair.rs | 58 ++++---- 4 files changed, 230 insertions(+), 120 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs index 1db799769..8e2cb0e26 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs @@ -354,48 +354,47 @@ mod state_machine_tests { /// balance over a correct canonical head. #[test] fn current_state_always_reflects_the_canonical_head_never_an_override() { - use crate::types::device_state::{BalanceDelta, BalanceDirection}; - use crate::types::operations::Operation; - let devid = [0x42u8; 32]; - let head0 = - crate::types::device_state::DeviceState::new(devid, devid, vec![0xAAu8; 32], 64); - - // Mint 275 to self so the head carries a real balance, exactly like 8XK. + // Install a real ERA balance on the head by RESTORE, not by minting. + // + // The subject here is that `current_state` reflects the canonical head rather + // than an override — how the head came to hold a balance is incidental. Minting + // is no longer a way to get one: `advance` refuses builtin issuance (ERA/dBTC + // are not self-authorizable), and only builtins survive the compat projection + // that `token_balances` is read from, so a synthetic asset would project as + // empty. `restore` is the honest fixture: it is exactly the shape a reloaded + // device has. let policy = crate::core::token::builtin_policy_commit_for_token("ERA").unwrap(); let rel = crate::core::bilateral_transaction_manager::compute_smt_key(&devid, &devid); let init = crate::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids( &devid, &devid, ); - let outcome = head0 - .advance( + let mut balances = std::collections::BTreeMap::new(); + balances.insert(policy, 275u64); + let restored = crate::types::device_state::DeviceState::restore( + devid, + devid, + vec![0xAAu8; 32], + None, + balances, + vec![( rel, - devid, - Operation::Mint { - amount: crate::types::token_types::Balance::from_state(275, [0u8; 32]), - token_id: b"ERA".to_vec(), - policy_commit: crate::core::token::builtin_policy_commit_for_token("ERA") - .unwrap(), - authorized_by: b"self".to_vec(), - proof_of_authorization: Vec::new(), - message: "mint".to_string(), + crate::types::device_state::RelChainTip { + chain_tip: init, + counterparty_devid: devid, + tip_entropy: vec![0x11u8; 32], + value_capability: crate::types::device_state::ValueCapability::Yes, }, - vec![0x11u8; 32], - None, - &[BalanceDelta { - policy_commit: policy, - direction: BalanceDirection::Credit, - amount: 275, - }], - Some(init), - None, - None, - None, - ) - .expect("mint advance"); + )], + std::collections::BTreeMap::new(), + std::collections::BTreeMap::new(), + std::collections::BTreeMap::new(), + 64, + ) + .expect("restore a head carrying a real balance"); let mut sm = StateMachine::new(); - sm.set_device_head(outcome.new_device_state.clone()); + sm.set_device_head(restored.clone()); let cs = sm.current_state().expect("state from head"); let era = cs @@ -408,11 +407,7 @@ mod state_machine_tests { era, 275, "current_state must reflect the canonical head's balance" ); - assert_eq!( - cs.hash, - outcome.new_device_state.root(), - "hash is the canonical SMT root" - ); + assert_eq!(cs.hash, restored.root(), "hash is the canonical SMT root"); } use crate::types::state_types::DeviceInfo; use crate::types::token_types::Balance; diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs b/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs index eeda36cb7..0983bf130 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs @@ -1383,6 +1383,51 @@ impl DeviceState { )?; } + // BUILTIN ISSUANCE IS NOT SELF-AUTHORIZABLE. + // + // A `Mint` naming a builtin policy commit (ERA, dBTC) creates units of a + // supply nobody may unilaterally expand. Every check that used to stand + // between a caller and that credit was satisfiable by the caller alone: + // the route builds its own authorization and stamps `authorized_by` with + // the caller's own device id; ERA's preloaded policy carries zero + // conditions and zero roles, so the enforcer iterates nothing and + // returns "allowed"; dBTC has no registered policy at all and takes the + // builtin escape hatch; and `validate_conservation` only checks that the + // single credit delta matches the amount and asset the same caller + // signed. Nothing anywhere established a right to issue. + // + // The gate lives HERE, at the accepting transition, and not on the route, + // because a route guard binds only the callers that go through it: any + // future route, or any direct `advance` caller, would silently reopen the + // hole. This is the chokepoint every mint must cross. + // + // Fail-closed with no exemption: `EXCEPT through an explicit issuance + // predicate whose evidence THIS verifier validates` is the intended + // shape, and no such predicate exists yet — so there is no admissible + // builtin issuance today rather than a placeholder one. A `SupplyCap` + // condition would NOT be that predicate: it reads `circulating_le` from + // caller-supplied enforcement context, and no canonical producer + // authenticates that number. + // Keyed on `policy_commit`, which is the identity that actually moves + // value: `validate_conservation` binds the credit delta to it, `balances` + // is keyed by it, and the compat projection resolves a ticker FROM it. + // The `token_id` string is metadata — a mint carrying the ticker "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 mints + // without closing anything. + if let Operation::Mint { policy_commit, .. } = &operation { + if let Some(name) = + crate::core::token::token_state_manager::builtin_token_id_for_policy_commit( + policy_commit, + ) + { + return Err(DsmError::invalid_operation(format!( + "advance: refusing to mint the builtin token {name} — builtin issuance is not \ + self-authorizable, and no authenticated issuance predicate is defined for it" + ))); + } + } + // Offline-bearer spend: draw the value from the device-bound offline-cash allocation instead of // the online balance. Requires the anchor-state advance (a bearer transfer always advances // the anchor leaf), so the allocation debit and the transition land in ONE atomic device root. @@ -2966,6 +3011,99 @@ mod tests { ); } + /// BUILTIN ISSUANCE IS REFUSED AT THE ACCEPTING TRANSITION. + /// + /// Not at the route — at `advance`, the chokepoint every mint must cross. + /// Before this gate, `token.mint {token_id: "ERA", amount: }` was a live + /// production route that credited the caller: the handler signs its own + /// authorization and stamps `authorized_by` with the caller's own device id; + /// ERA's preloaded policy has zero conditions and zero roles, so enforcement + /// returns "allowed"; dBTC has no policy at all and takes the builtin escape + /// hatch; and conservation only checks that the single credit matches the + /// amount and asset the same caller signed. + /// + /// MUTATION CONTROL: delete the builtin-issuance block in `advance` and this + /// test goes green by minting ERA from air — which is precisely the defect. + #[test] + fn a_builtin_token_cannot_be_minted_from_air_at_the_accepting_transition() { + for ticker in ["ERA", "dBTC"] { + let pc = crate::core::token::builtin_policy_commit_for_token(ticker) + .expect("builtin commit"); + let dev = DeviceState::new(devid(0xA1), devid(0xA1), vec![0x01; 32], 64); + let rk = + crate::core::bilateral_transaction_manager::compute_smt_key(&dev.devid, &dev.devid); + let tip = crate::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids( + &dev.devid, &dev.devid, + ); + let outcome = dev.advance( + rk, + dev.devid, + mint_op_for(u64::MAX, pc), + entropy(7), + None, + &[BalanceDelta { + policy_commit: pc, + direction: BalanceDirection::Credit, + amount: u64::MAX, + }], + Some(tip), + None, + None, + None, + ); + // Fail for the RIGHT reason — an `is_err()` assertion would pass just + // as happily on an unrelated error. + let err = format!( + "{}", + outcome.expect_err("minting a builtin token from air must be refused") + ); + assert!( + err.contains("builtin issuance is not self-authorizable") && err.contains(ticker), + "must fail as unauthorized builtin issuance naming {ticker}, got: {err}" + ); + } + } + + /// The gate is keyed on the ASSET, not the ticker string. A mint carrying a + /// builtin ticker with a non-builtin `policy_commit` credits that non-builtin + /// asset and can never project as ERA, so refusing it would reject honest + /// issuance without closing anything. This pins that the gate stays narrow. + #[test] + fn a_non_builtin_asset_still_mints_even_under_a_builtin_ticker() { + let pc = [0x5Au8; 32]; + assert!( + crate::core::token::token_state_manager::builtin_token_id_for_policy_commit(&pc) + .is_none(), + "fixture must not accidentally name a builtin" + ); + let dev = DeviceState::new(devid(0xA2), devid(0xA2), vec![0x02; 32], 64); + let rk = + crate::core::bilateral_transaction_manager::compute_smt_key(&dev.devid, &dev.devid); + let tip = crate::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids( + &dev.devid, &dev.devid, + ); + // `mint_op_for` hard-codes the ticker "ERA" while naming this commit. + let out = dev + .advance( + rk, + dev.devid, + mint_op_for(1_000, pc), + entropy(8), + None, + &[BalanceDelta { + policy_commit: pc, + direction: BalanceDirection::Credit, + amount: 1_000, + }], + Some(tip), + None, + None, + None, + ) + .expect("a non-builtin asset is unaffected by the builtin-issuance gate"); + assert_eq!(out.new_device_state.balance(&pc), 1_000); + } + // ── reserve authority: the PRODUCTION funding path ───────────────────── // // The tests above fund through `fund_vault_reserves`, a test-only shim that diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_routes.rs index b533f28fc..5c1dc9d40 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_routes.rs @@ -9,7 +9,6 @@ use prost::Message; use crate::bridge::{AppInvoke, AppQuery, AppResult}; use super::app_router_impl::{AppRouterImpl, FaucetState, build_testnet_faucet_policy}; use super::response_helpers::{pack_envelope_ok, err}; -use crate::util::deterministic_time as dt; impl AppRouterImpl { /// Dispatch handler for `faucet.check_nearby` query route. @@ -35,12 +34,15 @@ impl AppRouterImpl { return err("faucet.check_nearby: device_id must be 32 bytes".into()); } - // Testnet faucet - always available + // Builtin faucet issuance is unavailable (see `faucet.claim`): report + // it here rather than advertising a faucet that must refuse. let resp = generated::FaucetClaimResponse { - success: true, + success: false, tokens_received: 0, next_available_index: 0, - message: "Testnet faucet available".to_string(), + message: "builtin faucet issuance unavailable — no authenticated issuance \ + predicate is defined for ERA/dBTC" + .to_string(), }; // Return as Envelope.faucetClaimResponse (field 24) pack_envelope_ok(generated::envelope::Payload::FaucetClaimResponse(resp)) @@ -74,58 +76,29 @@ impl AppRouterImpl { return err("faucet.claim: device_id must be 32 bytes".into()); } - log::info!( - "[faucet.claim] device_id_b32={}", - crate::util::text_id::encode_base32_crockford(&dev) - ); - - let identity = crate::util::text_id::encode_base32_crockford(&dev); - let now = dt::tick(); - - let (amount, next_available) = { - let mut faucet = self.faucet_state.lock().await; - match faucet.claim(&identity, now) { - Ok(v) => v, - Err(msg) => return err(format!("faucet.claim: {msg}")), - } - }; - - log::info!( - "[faucet.claim] granted amount={} next_available_index={}", - amount, - next_available - ); - - match self.wallet.mint_for_self(amount, Some("ERA")).await { - Ok(_) => { - log::error!( - "[faucet.claim] ❗ mint_for_self succeeded, amount={}", - amount - ); - - // Verify the canonical projection row was updated. - let device_id_txt = - crate::util::text_id::encode_base32_crockford(&self.device_id_bytes); - if let Ok(Some(record)) = - crate::storage::client_db::get_balance_projection(&device_id_txt, "ERA") - { - log::error!("[faucet.claim] ❗ Post-mint ERA projection verification: device_id={} available={} locked={}", - device_id_txt, record.available, record.locked); - } else { - log::error!("[faucet.claim] ❌ Post-mint ERA projection verification FAILED: projection not found"); - } - - let resp = generated::FaucetClaimResponse { - success: true, - tokens_received: amount, - next_available_index: next_available, - message: "Faucet claim successful".to_string(), - }; - // NEW: Return as Envelope.faucetClaimResponse (field 24) - pack_envelope_ok(generated::envelope::Payload::FaucetClaimResponse(resp)) - } - Err(e) => err(format!("faucet.claim failed: {e}")), - } + // BUILTIN FAUCET ISSUANCE IS UNAVAILABLE. + // + // This route minted builtin ERA on the strength of a caller-supplied + // `device_id` plus a local cooldown — no independently verifiable right + // to issue, which is the same defect class the accepting-layer gate + // exists to close. `DeviceState::advance` now refuses builtin issuance + // outright, so the mint would fail regardless; refusing here names the + // reason instead of surfacing a confusing lower-layer error. + // + // This refusal is a MESSAGE, not the control. The authoritative gate is + // the one at the accepting transition — delete this block and nothing can + // be minted anyway. Do NOT turn it into an exemption, a dev flag that + // bypasses `advance`, or a magic faucet key: any of those would make the + // repair a disguised backdoor. Restoring a faucet means defining a real + // issuance predicate whose evidence the accepting layer validates. + // + // The minting body is DELETED rather than left unreachable: a dead path + // that still knows how to mint is the thing a later edit resurrects. + err( + "faucet.claim: builtin faucet issuance unavailable — no authenticated \ + issuance predicate is defined for ERA/dBTC" + .into(), + ) } // -------- faucet.clean (InvokeOp) -------- diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/projection_repair.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/projection_repair.rs index b242d8376..76edb1723 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/projection_repair.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/projection_repair.rs @@ -299,41 +299,45 @@ mod tests { /// Build a canonical head carrying `amount` ERA via a self-mint advance, and /// return (devid, head). fn head_with_era(amount: u64) -> ([u8; 32], dsm::types::device_state::DeviceState) { - use dsm::types::device_state::{BalanceDelta, BalanceDirection}; let devid = [0x8Cu8; 32]; - let base = dsm::types::device_state::DeviceState::new(devid, devid, vec![0xAAu8; 32], 64); let policy = crate::policy::builtin_policy_commit("ERA").unwrap(); let rel = dsm::core::bilateral_transaction_manager::compute_smt_key(&devid, &devid); let init = dsm::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids( &devid, &devid, ); - let outcome = base - .advance( + + // Install the ERA balance by RESTORE, not by minting. + // + // `advance` refuses builtin issuance (ERA/dBTC are not self-authorizable), and + // ERA specifically IS load-bearing here: the projection resolves a ticker FROM + // the builtin commit, so a synthetic asset would project as empty and these + // tests would assert against nothing. `restore` is the honest fixture — it is + // exactly the shape a reloaded device has, which is also the case these tests + // are about. + let mut balances = std::collections::BTreeMap::new(); + balances.insert(policy, amount); + let head = dsm::types::device_state::DeviceState::restore( + devid, + devid, + vec![0xAAu8; 32], + None, + balances, + vec![( rel, - devid, - dsm::types::operations::Operation::Mint { - amount: dsm::types::token_types::Balance::from_state(amount, [0u8; 32]), - token_id: b"ERA".to_vec(), - policy_commit: dsm::core::token::builtin_policy_commit_for_token("ERA") - .unwrap(), - authorized_by: b"self".to_vec(), - proof_of_authorization: Vec::new(), - message: "mint".to_string(), + dsm::types::device_state::RelChainTip { + chain_tip: init, + counterparty_devid: devid, + tip_entropy: vec![0x11u8; 32], + value_capability: dsm::types::device_state::ValueCapability::Yes, }, - vec![0x11u8; 32], - None, - &[BalanceDelta { - policy_commit: policy, - direction: BalanceDirection::Credit, - amount, - }], - Some(init), - None, - None, - None, - ) - .expect("mint advance"); - (devid, outcome.new_device_state) + )], + std::collections::BTreeMap::new(), + std::collections::BTreeMap::new(), + std::collections::BTreeMap::new(), + 64, + ) + .expect("restore a head carrying a real ERA balance"); + (devid, head) } /// THE 8XK CASE. Head intact at 275, projection empty (blanked out-of-band, no