diff --git a/Cargo.lock b/Cargo.lock index 7ac9720e..e6f5b18c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.185.0" +version = "0.187.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index f82e3d5d..7bc675a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.185.0" +version = "0.187.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index b56c11be..f0d62d3a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8112,6 +8112,17 @@ Mirror spends do not transit `control.wallet.broadcast`, and `DIG_WALLET_ENABLE_ does not govern them: that flag gates the GENERAL node-custodied wallet surface, while this lifecycle is governed by §25.7's switch and §23's audit contract. +**Mirror spends MUST be signed under CHIA MAINNET's `AGG_SIG_ME` domain.** A mirror coin is an +ordinary Chia L1 CAT, so the consensus that validates its spend appends Chia mainnet's genesis +challenge (`ccd5bb71183532bff220ba46c268991a3ff07eb358e8255a65c30a2dce0e5fbb`) to every `AGG_SIG_ME` +message. The `agg_sig_data` the operator wallet is opened with MUST therefore be +`MAINNET_CONSTANTS.genesis_challenge`, and MUST NOT be any `dig-constants` value: `dig-constants` +describes the **DIG L2** chain, and its genesis is the DIG PEER network id, not an L1 CAT's signing +domain. Signing under any other domain produces a valid signature over a message the network does +not check, so the bundle builds, signs and broadcasts and is then refused as +`BAD_AGGREGATE_SIGNATURE` by every peer, on every retry — with the collateral left locked and no +local error to see (dig-node#447). + **Key derivation MUST be the standard Chia HD derivation** from the operator mnemonic, so the phrase exported by `dign wallet export-seed` (§16.3) recovers the collateral wallet — including anything locked in unreclaimed mirror coins, via any standard wallet — with no dig-node code involved. The @@ -8407,3 +8418,4 @@ on a claim the node cannot keep, which this section penalises. The operator sets Changing the value affects only coins created after the change. Bringing an existing coin into line means reclaiming and re-creating it — a round trip and a fee — and the node MUST NOT reclaim in response to a configuration edit. + diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index cdd6b1dd..e187aa76 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -124,6 +124,11 @@ dig-chainsource-interface = "0.3" chia-bls = "0.36.1" chia-protocol = "0.36.1" chia-sdk-driver = { version = "0.36.0", features = ["chip-0035", "action-layer"] } +# `MAINNET_CONSTANTS` -- the Chia L1 `AGG_SIG_ME` domain every mirror-coin spend is signed under +# (`mirror::lifecycle::mirror_agg_sig_data`, dig-node#447). This is the crate `chia_wallet_sdk::types` +# re-exports, named directly so this crate does not pull the whole SDK for one constant. Already a +# dev-dependency at this exact version, so no second line enters the tree. +chia-sdk-types = { version = "0.36.0", features = ["chip-0035", "action-layer"] } # The CAT puzzle-hash currying, for deriving the coin a mirror RECLAIM creates before it exists. # Same chia 0.36 set as the crates around it -- a mirror coin's collateral is $DIG, so the returned # coin sits at the CAT puzzle hash and never at the bare owner puzzle hash. diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index 97be9629..0c063fab 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -656,6 +656,25 @@ pub async fn production_broadcaster( } } +/// The `AGG_SIG_ME` domain every mirror-coin spend must be signed under: **Chia mainnet's**. +/// +/// A mirror coin is an ordinary Chia L1 CAT, so the consensus that validates its spend appends +/// Chia mainnet's genesis challenge to every `AGG_SIG_ME` message. Signing under any other domain +/// produces a signature over a different message, and Chia's mempool answers +/// `BAD_AGGREGATE_SIGNATURE` -- deterministically, from every peer, on every retry. +/// +/// **`dig-constants` is the DIG L2 chain's constants crate and has no business in an L1 CAT spend.** +/// `DIG_MAINNET.genesis_challenge()` is the L2 genesis anchor; it is the right value for the DIG +/// peer network id (`peer::genesis_challenge_from_env`) and the wrong one here. Passing it locked +/// 1010 $DIG base units in an unspendable mirror coin on mainnet (dig-node#447), and the failure is +/// invisible locally: the bundle builds, signs, and broadcasts, and only the network disagrees. +/// +/// This is a function rather than an inline constant so the choice has a name a test can assert on, +/// and so the reason above lives beside the value instead of at one call site. +pub fn mirror_agg_sig_data() -> chia_protocol::Bytes32 { + chia_sdk_types::MAINNET_CONSTANTS.genesis_challenge +} + /// Open the operator wallet, or say why the lifecycle cannot spend. /// /// [`OperatorWallet::open`] returns `None` for BOTH §16.4 `Locked` and `Orphaned`, which is the @@ -666,8 +685,7 @@ pub async fn open_signer( live_broadcast: bool, chain: &ChainTransport, ) -> (Option, SpendCapability) { - let Some(wallet) = OperatorWallet::open(paths, dig_constants::DIG_MAINNET.genesis_challenge()) - else { + let Some(wallet) = OperatorWallet::open(paths, mirror_agg_sig_data()) else { return (None, SpendCapability::WalletUnavailable); }; if !live_broadcast { diff --git a/crates/dig-node-service/tests/mirror_l1_genesis.rs b/crates/dig-node-service/tests/mirror_l1_genesis.rs new file mode 100644 index 00000000..fca09b36 --- /dev/null +++ b/crates/dig-node-service/tests/mirror_l1_genesis.rs @@ -0,0 +1,184 @@ +//! A mirror-coin spend is signed under **Chia mainnet's** `AGG_SIG_ME` domain, never the DIG L2 one. +//! +//! A mirror coin is an ordinary Chia L1 CAT. The consensus that validates its spend appends Chia +//! mainnet's genesis challenge to every `AGG_SIG_ME` message, so a signature produced under any +//! other domain commits to a different message and the mempool answers `BAD_AGGREGATE_SIGNATURE` +//! from every peer, on every retry. +//! +//! dig-node#447 shipped exactly that: `open_signer` opened the operator wallet with +//! `dig_constants::DIG_MAINNET.genesis_challenge()`, the **L2** anchor, which locked 1010 $DIG base +//! units in an unspendable mirror coin on mainnet. Nothing local disagreed — the bundle built, +//! signed and broadcast; only the network refused. +//! +//! # Why these tests are shaped the way they are +//! +//! Asserting that the trailing 32 bytes of a required message equal a constant is circular when the +//! same constant is fed to the extractor. So the load-bearing test below signs with the wallet the +//! PRODUCTION selector builds, and then verifies that signature against the message Chia's own +//! consensus requires — a domain this file states independently of the code under test. A signature +//! made under the L2 domain cannot verify against the L1 message, so it cannot pass on the defect. + +mod support; + +use chia_bls::PublicKey; +use chia_protocol::{Bytes32, Coin, CoinSpend}; +use chia_sdk_types::MAINNET_CONSTANTS; +use dig_mirror_coin::MirrorCoin; +use dig_node_service::mirror::lifecycle::mirror_agg_sig_data; +use dig_node_service::mirror::spends::build_reclaim; +use dig_wallet::operator_wallet::OperatorWallet; +use dig_wallet::sage::spend::required_bls_signatures; +use support::{creating_spend, mirror_memos, root_1, store_a, Wallet}; + +const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon art"; + +/// Chia mainnet's genesis challenge, stated here rather than read from the code under test. +/// +/// This is the value the Chia mempool actually appends. Restating it makes every assertion below a +/// comparison against the NETWORK's constant instead of against the program's opinion of it. +/// +/// Decoded from the published hex rather than written as 32 byte literals so a transcription slip +/// cannot happen silently, and so the source reads as the value operators quote. +fn chia_mainnet_genesis() -> Bytes32 { + let mut out = [0u8; 32]; + hex::decode_to_slice( + "ccd5bb71183532bff220ba46c268991a3ff07eb358e8255a65c30a2dce0e5fbb", + &mut out, + ) + .expect("the published Chia mainnet genesis challenge is 32 bytes of hex"); + Bytes32::new(out) +} + +/// The wallet the PRODUCTION selector builds: the fixture phrase, opened under +/// `mirror_agg_sig_data()`. Nothing here restates the domain — that is the point. +fn production_wallet() -> OperatorWallet { + OperatorWallet::from_phrase(PHRASE, mirror_agg_sig_data()) + .expect("the fixture phrase derives an operator wallet") +} + +fn fixture_wallet() -> Wallet { + let keys = digstore_chain::keys::derive_wallet_keys(PHRASE).expect("the phrase derives"); + Wallet { + public_key: keys.synthetic_sk.public_key(), + puzzle_hash: keys.owner_puzzle_hash, + } +} + +/// A genuine mirror coin this wallet owns, from a real CAT spend executed to produce its conditions. +fn owned_mirror_coin(owner: &Wallet) -> MirrorCoin { + let memos = mirror_memos(owner, store_a(), root_1(), &["https://example.invalid"]); + let (spend, coin) = creating_spend(owner, &memos); + + MirrorCoin::from_creating_spend(&spend, coin.coin_id()) + .expect("the fixture spend decodes") + .expect("and it is a mirror coin") +} + +/// The spends of a real, zero-fee reclaim of a coin this wallet owns. +fn reclaim_spends() -> Vec { + let owner = fixture_wallet(); + let coin = owned_mirror_coin(&owner); + build_reclaim(&coin, owner.public_key, Vec::::new(), 0) + .expect("a zero-fee reclaim builds") + .coin_spends() + .to_vec() +} + +/// Every BLS signature a reclaim requires under the Chia L1 domain, as `(key, message)`. +fn required_under_chia_l1() -> Vec<(PublicKey, Vec)> { + required_bls_signatures(&reclaim_spends(), chia_mainnet_genesis()) + .expect("required signatures extract") +} + +/// **The regression.** The signature the production wallet produces verifies against the message +/// Chia's consensus requires. +/// +/// This is the assertion the network was making and we were not. Under the L2 domain the wallet +/// signs a message ending in `0af98186…` while Chia checks one ending in `ccd5bb71…`, so the +/// aggregate cannot verify — and this test fails on its own assertion, not on a build error and not +/// on a missing fixture. +#[test] +fn a_reclaim_signed_by_the_production_wallet_verifies_under_the_chia_l1_domain() { + let required = required_under_chia_l1(); + assert!( + !required.is_empty(), + "a reclaim must require at least one BLS signature, or this test proves nothing" + ); + + let signature = production_wallet() + .signer() + .sign(&reclaim_spends()) + .expect("the production wallet signs its own reclaim"); + + let pairs = required + .iter() + .map(|(key, message)| (*key, message.as_slice())); + + assert!( + chia_bls::aggregate_verify(&signature, pairs), + "the operator wallet must sign the message CHIA validates -- a mirror coin is an L1 CAT, \ + and a signature made under any other genesis is rejected as BAD_AGGREGATE_SIGNATURE" + ); +} + +/// The message's trailing 32 bytes ARE the domain, and the domain the production selector chooses is +/// Chia mainnet's. +/// +/// Stated separately from the verification above so a failure says WHICH half broke: a wrong domain +/// here, or a wrong key or derivation there. +#[test] +fn the_required_message_ends_in_the_chia_mainnet_genesis_challenge() { + let required = required_under_chia_l1(); + assert!(!required.is_empty(), "no required signature to inspect"); + + for (_, message) in &required { + assert!( + message.len() > 32, + "an AGG_SIG_ME message carries a 32-byte domain after its payload" + ); + assert_eq!( + &message[message.len() - 32..], + chia_mainnet_genesis().as_ref(), + "the trailing 32 bytes of an AGG_SIG_ME message are the network domain" + ); + } + + assert_eq!( + mirror_agg_sig_data(), + chia_mainnet_genesis(), + "the production selector must choose the domain those messages are built from" + ); + assert_eq!( + mirror_agg_sig_data(), + MAINNET_CONSTANTS.genesis_challenge, + "and it must be the SDK's mainnet constant, not a second copy of the same bytes" + ); +} + +/// **The class guard.** No `dig-constants` genesis may ever be the mirror signing domain. +/// +/// The test above pins one wrong value; this pins the family it came from. `dig-constants` describes +/// the **DIG L2** chain, and nothing in it is an `AGG_SIG_ME` domain for a Chia L1 CAT. An edit that +/// reintroduces this genesis — or reaches for a different `dig_constants` network — fails here even +/// if it happens to satisfy nothing else. +#[test] +fn the_mirror_signing_domain_is_never_a_dig_constants_genesis() { + let chosen = mirror_agg_sig_data(); + + // A `Vec` rather than an array literal so the guard reads as a LIST that grows: adding a network + // to `dig-constants` and reaching for it here should be caught, not silently outside the guard. + let l2_genesis_values: Vec<(&str, Bytes32)> = vec![( + "DIG_MAINNET", + dig_constants::DIG_MAINNET.genesis_challenge(), + )]; + + for (name, genesis) in l2_genesis_values { + assert_ne!( + chosen, genesis, + "dig_constants::{name} is a DIG L2 anchor and must never be the mirror signing \ + domain; using it locked 1010 $DIG in an unspendable mainnet coin (dig-node#447)" + ); + } +} diff --git a/crates/dig-wallet/src/sage/spend.rs b/crates/dig-wallet/src/sage/spend.rs index 4930d79d..5eef8652 100644 --- a/crates/dig-wallet/src/sage/spend.rs +++ b/crates/dig-wallet/src/sage/spend.rs @@ -157,6 +157,26 @@ pub fn required_signatures( .map_err(|e| Error::internal(format!("required-signature extraction: {e:?}"))) } +/// Every BLS signature `coin_spends` requires, as `(public_key, message)` pairs. +/// +/// The message is what the CONSENSUS will hash under `agg_sig_data`, so a caller holding these can +/// check a produced signature against the network's own requirement rather than against the +/// signer's opinion of it. That distinction is the whole point: a signer asked for the message it +/// already believes in will always agree with itself, and the mirror-coin L1/L2 genesis regression +/// (dig-node#447) was invisible to every such self-consistent check. +pub fn required_bls_signatures( + coin_spends: &[CoinSpend], + agg_sig_data: Bytes32, +) -> Result)>> { + Ok(required_signatures(coin_spends, agg_sig_data)? + .into_iter() + .filter_map(|req| match req { + RequiredSignature::Bls(bls) => Some((bls.public_key, bls.message().to_vec())), + _ => None, + }) + .collect()) +} + /// The BLS public keys that must sign `coin_spends` for it to be valid. /// /// The push guard asks this instead of inspecting puzzle hashes, because a puzzle hash tells you