diff --git a/.changelog/unreleased/improvements/3833-pre-genesis-chain-id.md b/.changelog/unreleased/improvements/3833-pre-genesis-chain-id.md new file mode 100644 index 00000000000..3927553acc7 --- /dev/null +++ b/.changelog/unreleased/improvements/3833-pre-genesis-chain-id.md @@ -0,0 +1,2 @@ +- Let user customize the pre-genesis chain-id via environment variable + ([\#3833](https://github.com/anoma/namada/pull/3833)) \ No newline at end of file diff --git a/.changelog/unreleased/improvements/3843-check-genesis.md b/.changelog/unreleased/improvements/3843-check-genesis.md new file mode 100644 index 00000000000..a2b8320b47d --- /dev/null +++ b/.changelog/unreleased/improvements/3843-check-genesis.md @@ -0,0 +1,3 @@ +- Validate a chain ID of genesis on ABCI InitChain request + prior to applying it to ensure it's not been tampered with. + ([\#3843](https://github.com/anoma/namada/pull/3843)) \ No newline at end of file diff --git a/.github/workflows/scripts/e2e.json b/.github/workflows/scripts/e2e.json index 3bf68053133..5a4f302dbf5 100644 --- a/.github/workflows/scripts/e2e.json +++ b/.github/workflows/scripts/e2e.json @@ -29,5 +29,7 @@ "e2e::wallet_tests::wallet_encrypted_key_cmds": 1, "e2e::wallet_tests::wallet_encrypted_key_cmds_env_var": 1, "e2e::wallet_tests::wallet_unencrypted_key_cmds": 1, - "e2e::ledger_tests::masp_txs_and_queries": 82 + "e2e::ledger_tests::masp_txs_and_queries": 82, + "e2e::ledger_tests::test_genesis_chain_id_change": 35, + "e2e::ledger_tests::test_genesis_manipulation": 103 } \ No newline at end of file diff --git a/crates/apps_lib/src/config/genesis/chain.rs b/crates/apps_lib/src/config/genesis/chain.rs index ac4ad408c40..e23448e7809 100644 --- a/crates/apps_lib/src/config/genesis/chain.rs +++ b/crates/apps_lib/src/config/genesis/chain.rs @@ -4,6 +4,7 @@ use std::str::FromStr; use borsh::{BorshDeserialize, BorshSerialize}; use borsh_ext::BorshSerializeExt; +use eyre::eyre; use namada_macros::BorshDeserializer; #[cfg(feature = "migrations")] use namada_migrations::*; @@ -106,6 +107,8 @@ impl Finalized { /// Try to read all genesis and the chain metadata TOML files from the given /// directory. + /// + /// The consistency of the files is checked with [`Finalized::is_valid`]. pub fn read_toml_files(input_dir: &Path) -> eyre::Result { let vps_file = input_dir.join(templates::VPS_FILE_NAME); let tokens_file = input_dir.join(templates::TOKENS_FILE_NAME); @@ -121,14 +124,20 @@ impl Finalized { let parameters = read_toml(¶meters_file, "Parameters")?; let transactions = read_toml(&transactions_file, "Transactions")?; let metadata = read_toml(&metadata_file, "Chain metadata")?; - Ok(Self { + let genesis = Self { vps, tokens, balances, parameters, transactions, metadata, - }) + }; + + if !genesis.is_valid() { + return Err(eyre!("Invalid genesis files")); + } + + Ok(genesis) } /// Find the address of the configured native token @@ -485,6 +494,54 @@ impl Finalized { pub fn get_token_address(&self, alias: &Alias) -> Option<&Address> { self.tokens.token.get(alias).map(|token| &token.address) } + + // Validate the chain ID against the genesis contents + pub fn is_valid(&self) -> bool { + let Self { + vps, + tokens, + balances, + parameters, + transactions, + metadata, + } = self.clone(); + let Metadata { + chain_id, + genesis_time, + consensus_timeout_commit, + address_gen, + } = metadata.clone(); + + let Some(chain_id_prefix) = chain_id.prefix() else { + tracing::warn!( + "Invalid Chain ID \"{chain_id}\" - unable to find a prefix" + ); + return false; + }; + let metadata = Metadata { + chain_id: chain_id_prefix.clone(), + genesis_time, + consensus_timeout_commit, + address_gen, + }; + let to_finalize = ToFinalize { + vps, + tokens, + balances, + parameters, + transactions, + metadata, + }; + let derived_chain_id = derive_chain_id(chain_id_prefix, &to_finalize); + let is_valid = derived_chain_id == chain_id; + if !is_valid { + tracing::warn!( + "Invalid chain ID. This indicates that something in the \ + genesis files might have been modified." + ); + } + is_valid + } } /// Create the [`Finalized`] chain configuration. Derives the chain ID from the @@ -541,8 +598,7 @@ pub fn finalize( parameters, transactions, }; - let to_finalize_bytes = to_finalize.serialize_to_vec(); - let chain_id = ChainId::from_genesis(chain_id_prefix, to_finalize_bytes); + let chain_id = derive_chain_id(chain_id_prefix, &to_finalize); // Construct the `Finalized` chain let ToFinalize { @@ -575,6 +631,15 @@ pub fn finalize( } } +/// Derive a chain ID from genesis contents +pub fn derive_chain_id( + chain_id_prefix: ChainIdPrefix, + to_finalize: &ToFinalize, +) -> ChainId { + let to_finalize_bytes = to_finalize.serialize_to_vec(); + ChainId::from_genesis(chain_id_prefix, to_finalize_bytes) +} + /// Chain genesis config to be finalized. This struct is used to derive the /// chain ID to construct a [`Finalized`] chain genesis config. #[derive( diff --git a/crates/apps_lib/src/config/genesis/transactions.rs b/crates/apps_lib/src/config/genesis/transactions.rs index 4ab224db20a..1277d19dd4d 100644 --- a/crates/apps_lib/src/config/genesis/transactions.rs +++ b/crates/apps_lib/src/config/genesis/transactions.rs @@ -1,6 +1,7 @@ //! Genesis transactions use std::collections::{BTreeMap, BTreeSet}; +use std::env; use std::fmt::Debug; use std::net::SocketAddr; @@ -46,6 +47,7 @@ use crate::config::genesis::{utils, GenesisAddress}; use crate::wallet::{CliWalletUtils, WalletTransport}; /// Dummy chain id used to sign [`Tx`] objects at pre-genesis. +pub const NAMADA_GENESIS_TX_ENV_VAR: &str = "NAMADA_GENESIS_TX_CHAIN_ID"; const NAMADA_GENESIS_TX_CHAIN_ID: &str = "namada-genesis"; /// Helper trait to fetch tx data to sign. @@ -62,6 +64,15 @@ pub trait TxToSign { fn get_owner(&self) -> GenesisAddress; } +/// Get the genesis chain ID from "NAMADA_GENESIS_TX_CHAIN_ID" env var, if set, +/// or the default +pub fn genesis_chain_id() -> ChainId { + ChainId( + env::var(NAMADA_GENESIS_TX_ENV_VAR) + .unwrap_or_else(|_| NAMADA_GENESIS_TX_CHAIN_ID.to_string()), + ) +} + /// Return a dummy set of tx arguments to sign with the /// hardware wallet. fn get_tx_args(use_device: bool) -> TxArgs { @@ -107,7 +118,7 @@ fn pre_genesis_tx_timestamp() -> DateTimeUtc { /// Return a ready to sign genesis [`Tx`]. fn get_tx_to_sign(tag: impl AsRef, data: impl BorshSerialize) -> Tx { let mut tx = Tx::from_type(TxType::Raw); - tx.header.chain_id = ChainId(NAMADA_GENESIS_TX_CHAIN_ID.to_string()); + tx.header.chain_id = genesis_chain_id(); tx.header.timestamp = pre_genesis_tx_timestamp(); tx.set_code(Code { salt: [0; 8], diff --git a/crates/core/src/chain.rs b/crates/core/src/chain.rs index 44f76556afc..552908aa09e 100644 --- a/crates/core/src/chain.rs +++ b/crates/core/src/chain.rs @@ -109,6 +109,13 @@ impl ChainId { } errors } + + /// Find the prefix of a valid ChainId. + pub fn prefix(&self) -> Option { + let ChainId(chain_id) = self; + let (prefix, _) = chain_id.rsplit_once(CHAIN_ID_PREFIX_SEP)?; + Some(ChainIdPrefix(prefix.to_string())) + } } /// Height of a block, i.e. the level. The `default` is the diff --git a/crates/node/src/shell/init_chain.rs b/crates/node/src/shell/init_chain.rs index 05624f1871a..f4271769110 100644 --- a/crates/node/src/shell/init_chain.rs +++ b/crates/node/src/shell/init_chain.rs @@ -133,14 +133,14 @@ where let genesis = { let chain_dir = self.base_dir.join(chain_id); genesis::chain::Finalized::read_toml_files(&chain_dir) - .expect("Missing genesis files") + .expect("Missing or invalid genesis files") }; #[cfg(any(test, fuzzing, feature = "benches"))] let genesis = { let chain_dir = self.base_dir.join(chain_id); if chain_dir.join(genesis::chain::METADATA_FILE_NAME).exists() { genesis::chain::Finalized::read_toml_files(&chain_dir) - .expect("Missing genesis files") + .expect("Missing or invalid genesis files") } else { genesis::make_dev_genesis(num_validators, &chain_dir) } diff --git a/crates/node/src/utils.rs b/crates/node/src/utils.rs index b2c02f40b24..7f2ef01b371 100644 --- a/crates/node/src/utils.rs +++ b/crates/node/src/utils.rs @@ -19,7 +19,10 @@ pub fn test_genesis(args: TestGenesis, global_args: args::Global) { check_can_sign, } = args; - let templates = genesis::templates::load_and_validate(&path).unwrap(); + let Some(templates) = genesis::templates::load_and_validate(&path) else { + eprintln!("Unable to load the genesis templates"); + cli::safe_exit(1); + }; let genesis = genesis::chain::finalize( templates, FromStr::from_str("namada-dryrun").unwrap(), diff --git a/crates/tests/src/e2e/ledger_tests.rs b/crates/tests/src/e2e/ledger_tests.rs index dceed2b2acb..eed326daed4 100644 --- a/crates/tests/src/e2e/ledger_tests.rs +++ b/crates/tests/src/e2e/ledger_tests.rs @@ -10,6 +10,7 @@ //! `NAMADA_E2E_KEEP_TEMP=true`. #![allow(clippy::type_complexity)] +use std::env; use std::fmt::Display; use std::path::PathBuf; use std::process::Command; @@ -21,14 +22,16 @@ use color_eyre::eyre::Result; use color_eyre::owo_colors::OwoColorize; use namada_apps_lib::cli::context::ENV_VAR_CHAIN_ID; use namada_apps_lib::client::utils::PRE_GENESIS_DIR; +use namada_apps_lib::config::genesis::chain; +use namada_apps_lib::config::genesis::templates::TokenBalances; use namada_apps_lib::config::utils::convert_tm_addr_to_socket_addr; use namada_apps_lib::config::{self, ethereum_bridge}; use namada_apps_lib::tendermint_config::net::Address as TendermintAddress; -use namada_apps_lib::wallet; +use namada_apps_lib::wallet::{self, Alias}; use namada_core::chain::ChainId; use namada_core::token::NATIVE_MAX_DECIMAL_PLACES; use namada_sdk::address::Address; -use namada_sdk::chain::Epoch; +use namada_sdk::chain::{ChainIdPrefix, Epoch}; use namada_sdk::time::DateTimeUtc; use namada_sdk::token; use namada_test_utils::TestWasms; @@ -2593,7 +2596,6 @@ fn masp_txs_and_queries() -> Result<()> { /// Test localnet genesis files with `namada node utils test-genesis` command. #[test] fn test_localnet_genesis() -> Result<()> { - let loc = format!("{}:{}", std::file!(), std::line!()); let base_dir = setup::TestDir::new(); let working_dir = working_dir(); let genesis_path = wallet::defaults::derive_template_dir(&working_dir); @@ -2630,12 +2632,175 @@ fn test_localnet_genesis() -> Result<()> { Some(30), &working_dir, &base_dir, - loc, + format!("{}:{}", std::file!(), std::line!()), )?; test_genesis_result .exp_string("Genesis files were dry-run successfully")?; test_genesis_result.exp_string("Able to sign with")?; test_genesis_result.exp_string("Able to sign with")?; test_genesis_result.exp_string("Able to sign with")?; + + // Use a non-default "NAMADA_GENESIS_TX_CHAIN_ID" + env::set_var( + config::genesis::transactions::NAMADA_GENESIS_TX_ENV_VAR, + "e2e-test-genesis", + ); + + let mut test_genesis_result = setup::run_cmd( + Bin::Node, + [ + "utils", + "test-genesis", + "--path", + &genesis_path.to_string_lossy(), + "--wasm-dir", + &wasm_dir.to_string_lossy(), + ], + Some(30), + &working_dir, + &base_dir, + format!("{}:{}", std::file!(), std::line!()), + )?; + // Signature should be invalid now + test_genesis_result.exp_string("Invalid validator account signature")?; + test_genesis_result.exp_string("Invalid bond tx signature")?; + test_genesis_result.exp_string("Invalid bond tx signature")?; + test_genesis_result.assert_failure(); + + Ok(()) +} + +/// Test change of genesis chain ID via "NAMADA_GENESIS_TX_CHAIN_ID" env var +#[test] +fn test_genesis_chain_id_change() -> Result<()> { + // Use a non-default "NAMADA_GENESIS_TX_CHAIN_ID" + env::set_var( + config::genesis::transactions::NAMADA_GENESIS_TX_ENV_VAR, + "e2e-test-genesis", + ); + + let working_dir = working_dir(); + let wasm_dir = working_dir.join(config::DEFAULT_WASM_DIR); + + let test = setup::network( + |mut genesis, base_dir: &_| { + // Empty the transactions as their signatures are invalid - created + // with the default genesis chain ID + genesis.transactions = Default::default(); + genesis.parameters.pgf_params.stewards = Default::default(); + + setup::set_validators(1, genesis, base_dir, |_| 0u16, vec![]) + }, + None, + ) + .unwrap(); + + let genesis_templates = test.test_dir.path().join("templates"); + let base_dir = test.get_base_dir(Who::Validator(0)); + let mut test_genesis_result = setup::run_cmd( + Bin::Node, + [ + "utils", + "test-genesis", + "--path", + &genesis_templates.to_string_lossy(), + "--wasm-dir", + &wasm_dir.to_string_lossy(), + ], + Some(30), + &working_dir, + &base_dir, + format!("{}:{}", std::file!(), std::line!()), + )?; + test_genesis_result + .exp_string("Genesis files were dry-run successfully")?; + + set_ethereum_bridge_mode( + &test, + &test.net.chain_id, + Who::Validator(0), + ethereum_bridge::ledger::Mode::Off, + None, + ); + + // Unset the chain ID - the transaction signatures have been validated at + // init-network so we don't need it anymore + env::remove_var(config::genesis::transactions::NAMADA_GENESIS_TX_ENV_VAR); + // Start the ledger as a validator + let _bg_validator_0 = + start_namada_ledger_node_wait_wasm(&test, Some(0), Some(40))? + .background(); + + let rpc = get_actor_rpc(&test, Who::Validator(0)); + wait_for_block_height(&test, &rpc, 2, 30)?; + + Ok(()) +} + +/// Test that any changes done to a genesis config after a chain is finalized +/// will make it fail validation. +#[test] +fn test_genesis_manipulation() -> Result<()> { + let test = setup::single_node_net().unwrap(); + + set_ethereum_bridge_mode( + &test, + &test.net.chain_id, + Who::Validator(0), + ethereum_bridge::ledger::Mode::Off, + None, + ); + + let chain_dir = test.get_chain_dir(Who::Validator(0)); + let genesis = chain::Finalized::read_toml_files(&chain_dir).unwrap(); + + let modified_genesis = [ + { + let mut genesis = genesis.clone(); + genesis + .balances + .token + .insert(Alias::from("test"), TokenBalances(Default::default())); + genesis + }, + { + let mut genesis = genesis.clone(); + genesis.balances.token.remove(&Alias::from("NAM")); + genesis + }, + { + let mut genesis = genesis.clone(); + genesis.metadata.address_gen = None; + genesis + }, + { + let mut genesis = genesis.clone(); + // Invalid chain ID + genesis.metadata.chain_id = ChainId("Invalid ID".to_string()); + genesis + }, + { + let mut genesis = genesis.clone(); + // Random valid chain ID + genesis.metadata.chain_id = ChainId::from_genesis( + ChainIdPrefix::from_str("TEST").unwrap(), + [1, 2, 3], + ); + genesis + }, + ]; + + for genesis in modified_genesis { + // Any modification should invalide the genesis + assert!(!genesis.is_valid()); + + genesis.write_toml_files(&chain_dir).unwrap(); + + // A node should fail to start-up + let result = + start_namada_ledger_node_wait_wasm(&test, Some(0), Some(40)); + assert!(result.is_err()) + } + Ok(()) } diff --git a/crates/tests/src/e2e/setup.rs b/crates/tests/src/e2e/setup.rs index b9b709a913a..035c7c9cc48 100644 --- a/crates/tests/src/e2e/setup.rs +++ b/crates/tests/src/e2e/setup.rs @@ -1098,6 +1098,13 @@ where Bin::Relayer => ("namadar", "info"), }; + let mut args = args.into_iter().peekable(); + let is_node_ledger = matches!(bin, Bin::Node) + && args + .peek() + .map(|fst_arg| fst_arg.as_ref() == "ledger") + .unwrap_or_default(); + let mut run_cmd = generate_bin_command( bin_name, &working_dir.as_ref().join("Cargo.toml"), @@ -1160,7 +1167,7 @@ where println!("{}:\n{}", "> Running".underline().green(), &cmd_process); - if let Bin::Node = &bin { + if is_node_ledger { // When running a node command, we need to wait a bit before checking // status sleep(1);