iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 536772bdcc469037581341da1e02519693552315
parent 1d747dac316cb28b7a8511a4c8fd43a140954751
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sat, 29 Aug 2026 13:14:32 +0200

Bind transactions to chain identity

Diffstat:
Mdocs/protocol.md | 10+++++++++-
Mdocs/security-review.md | 7+++++++
Msrc/adapters/chain_store/compact.rs | 34+++++++++++++++++++++++++++++++---
Msrc/adapters/ui_index.rs | 16+++++-----------
Msrc/domain.rs | 7+++++--
Msrc/domain/adversarial_tests.rs | 9+++------
Msrc/domain/genesis.rs | 36++++++++++++++++++++++++++----------
Msrc/domain/ledger_apply.rs | 5++++-
Msrc/domain/ledger_builders.rs | 35+++++++++++++++++++++++++++++------
Msrc/domain/ledger_chain.rs | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/domain/ledger_mempool.rs | 8+++++---
Msrc/domain/ledger_ops.rs | 12++++++++----
Msrc/domain/ledger_pending.rs | 36+++++++++++++++++++++++++-----------
Msrc/domain/ledger_queries.rs | 7+++++++
Msrc/domain/mining.rs | 31+++++++++++++++++++++++--------
Msrc/domain/stratum.rs | 43+++++++++++++++++++++++++++++--------------
Msrc/domain/transaction.rs | 300++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/domain/validation.rs | 38++++++++++++++++++++++++++++++++------
Msrc/domain/wallet.rs | 6+++++-
19 files changed, 732 insertions(+), 110 deletions(-)

diff --git a/docs/protocol.md b/docs/protocol.md @@ -48,7 +48,7 @@ The current mainnet-candidate parameter set is intentionally close to Bitcoin wh Changing any value in this section requires a conscious mainnet-candidate reset or later hard-fork process. -If the mainnet-candidate network is promoted to mainnet, the candidate genesis, chain history, UTXOs, tickets, and launch profile remain intact. A later P2P network ID change to `iuna-mainnet-v1` is only a peer-network cutover unless it is accompanied by an explicitly announced hard fork or reset. +Mainnet must start from a newly generated genesis and a distinct mainnet chain ID. The candidate chain, its UTXOs, and its signatures cannot be promoted in place: transaction signing format v1 intentionally makes old signatures invalid on the new genesis. A chain-ID change is therefore a consensus reset, not only a P2P network cutover. The consensus block-size limit is the exact number of bytes produced by the compact snapshot v6 block-body encoder when the block is appended to its parent chain. The encoder's reference tables are seeded by genesis allocations and extended in chain order, so all nodes calculate the same context-dependent size. The snapshot header, launch profile, block-count field, SQLite row metadata, and SQLite page overhead are not charged to an individual block. @@ -66,6 +66,14 @@ iuna uses a UTXO-style ledger. The main transaction types are: Burn and transfer fees are chosen by the sender. Mine action reward and mine action fee are deterministic protocol values. +### Transaction signing format v1 + +Every transfer, burn, and mine action is cryptographically scoped to one chain. Its signing or proof preimage starts with the fixed `IUNA-TX` type tag, the big-endian signing-format version `1`, a length-prefixed UTF-8 chain ID, and the length-prefixed 32-byte genesis block hash. The remaining payload uses an explicit one-byte transaction type and canonical binary fields: big-endian fixed-width integers, length-prefixed decoded hashes, signatures and Ed25519 keys, and ordered input/output counts. JSON spelling, field order, and separators never enter the sighash. All hexadecimal wire fields must use canonical lowercase encoding; alternate casing is rejected before signature validation so it cannot malleate addresses, transaction IDs, block hashes, or persisted snapshots. + +Transfers and burns use Ed25519 over this binary preimage. Native and Stratum mine proofs commit the same domain and logical mine fields before proof-specific hashing. Validators reconstruct the domain from their local launch profile and genesis block, so a transaction valid on candidate, mainnet, testnet, or another genesis fails signature/proof validation everywhere else. There is no legacy-signature fallback. + +Synthetic genesis-allocation outpoints use a separate typed binary commitment over the chain ID and allocation address. This avoids a circular dependency on the final genesis block hash while ensuring that otherwise identical allocations on different network identities do not create the same outpoints. Changing the chain ID or signing format requires a new genesis. + ## Burns Become Tickets A burn does not immediately select its own block. Instead: diff --git a/docs/security-review.md b/docs/security-review.md @@ -53,6 +53,13 @@ Primary code: - `src/domain/selection.rs` - `src/domain/validation.rs` +Transaction signing uses binary format v1 and commits the launch-profile chain ID +and local genesis hash for transfers, burns, native mine actions, and Stratum mine +actions. Genesis allocation outpoints are independently scoped to the chain ID. +Fixed vectors and replay tests cover candidate/mainnet/testnet IDs, distinct +genesis hashes, legacy text signatures, identical allocations, and hexadecimal +casing malleability across validation and compact persistence. + Evidence already in the tree: - focused adversarial tests for zero-fee burns, bundle import ordering, diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs @@ -654,14 +654,14 @@ impl CompactWriter { } fn hex(&mut self, value: &str) -> Result<()> { - let bytes = decode_hex(value)?; + let bytes = decode_canonical_hex(value)?; self.varint(bytes.len() as u64); self.bytes(&bytes); Ok(()) } fn fixed_hex<const N: usize>(&mut self, value: &str, label: &str) -> Result<()> { - let bytes = decode_hex(value).with_context(|| format!("invalid {label}"))?; + let bytes = decode_canonical_hex(value).with_context(|| format!("invalid {label}"))?; if bytes.len() != N { bail!("invalid {label}: expected {N} bytes, got {}", bytes.len()); } @@ -687,7 +687,7 @@ impl CompactWriter { self.varint(*index); return Ok(()); } - let bytes = decode_hex(value).context("invalid protocol id")?; + let bytes = decode_canonical_hex(value).context("invalid protocol id")?; match bytes.len() { 32 => self.u8(1), 64 => self.u8(2), @@ -890,6 +890,14 @@ fn decode_hex(input: &str) -> Result<Vec<u8>> { Ok(bytes) } +fn decode_canonical_hex(input: &str) -> Result<Vec<u8>> { + let bytes = decode_hex(input)?; + if hex_encode(&bytes) != input { + bail!("hex must use canonical lowercase encoding"); + } + Ok(bytes) +} + fn hex_value(byte: u8) -> Result<u8> { match byte { b'0'..=b'9' => Ok(byte - b'0'), @@ -1033,6 +1041,26 @@ mod tests { } #[test] + fn compact_transaction_writer_rejects_noncanonical_hex() { + let signature = "3".repeat(128); + let transaction = Transaction::Transfer { + inputs: vec![input(&"2".repeat(64), &signature)], + outputs: vec![TxOutput { + address: "AB".repeat(32), + amount: 10, + }], + fee: 1, + signature, + }; + let mut writer = CompactWriter::default(); + + let error = encode_transaction(&mut writer, &transaction, &mut EncodeTables::default()) + .unwrap_err(); + + assert!(format!("{error:#}").contains("canonical lowercase")); + } + + #[test] fn repeated_burn_references_shrink_to_small_varints() { let owner = "2".repeat(64); let signature = "3".repeat(128); diff --git a/src/adapters/ui_index.rs b/src/adapters/ui_index.rs @@ -1,8 +1,8 @@ use std::collections::BTreeMap; use crate::domain::{ - Block, BurnLeaderRank, ChainSnapshot, Ledger, OutPoint, Transaction, TxOutput, hex_hash, - reward_outputs_for_block, + Block, BurnLeaderRank, ChainSnapshot, Ledger, OutPoint, Transaction, TxOutput, + genesis_allocation_outpoint, reward_outputs_for_block, }; #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -62,7 +62,7 @@ fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOu continue; } outputs.insert( - genesis_allocation_outpoint(address), + genesis_allocation_outpoint(&snapshot.launch_profile.profile_id, address), TxOutput { address: address.clone(), amount: *amount, @@ -116,13 +116,6 @@ fn index_transaction_outputs( } } -fn genesis_allocation_outpoint(address: &str) -> OutPoint { - OutPoint { - txid: hex_hash(format!("iuna-genesis-allocation:{address}")), - index: 0, - } -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -145,7 +138,8 @@ mod tests { }; let index = build_ui_chain_index(&snapshot); - let outpoint = genesis_allocation_outpoint(wallet.address()); + let outpoint = + genesis_allocation_outpoint(&snapshot.launch_profile.profile_id, wallet.address()); assert_eq!( index.outputs.get(&outpoint).map(|output| output.amount), diff --git a/src/domain.rs b/src/domain.rs @@ -35,7 +35,7 @@ pub use block::{ Block, BurnLeaderRank, ChainSnapshot, ChainStatus, FinalizerMode, LeaderProof, PreparedBlock, }; use fork::LeaderScore; -use genesis::genesis_allocation_outpoint; +pub(crate) use genesis::genesis_allocation_outpoint; pub use hex::hex_hash; use hex::{decode_hex, decode_hex_array, hex_encode}; use ledger_lineage::{ @@ -78,7 +78,10 @@ pub use stratum::{ }; use stratum::{hash_meets_difficulty, stratum_mine_header_bytes, stratum_mine_signature}; use ticket::{BurnTicket, ticket_block_min_timestamp}; -pub use transaction::{MineSearchOutcome, OutPoint, Transaction, TxInput, TxOutput}; +pub use transaction::{ + MineSearchOutcome, OutPoint, TRANSACTION_SIGNING_FORMAT_VERSION, Transaction, TxInput, TxOutput, +}; +use transaction::{TransactionSigningDomain, mine_signing_bytes}; pub use validation::validate_address; use validation::{ canonical_transaction_size_bytes, validate_hash, validate_protocol_id, validate_signature, diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -14,8 +14,8 @@ use super::{ BurnBundleSignature, BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, FinalizerMode, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, GenesisBurn, LeaderProofPayload, Ledger, MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, MICRO_IUNA, MaskedBurn, OutPoint, Transaction, - TransactionSubmitOutcome, TxOutput, UtxoLineageRoot, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, - reward_outputs_for_block, run_vdf, + TransactionSubmitOutcome, TxOutput, UtxoLineageRoot, VDF_TARGET_BLOCK_MS, Wallet, + genesis_allocation_outpoint, hex_hash, reward_outputs_for_block, run_vdf, }; const NOW_MS: u64 = 10_000_000_000; @@ -1430,10 +1430,7 @@ fn mini_lineage_state(snapshot: &ChainSnapshot) -> Option<MiniLineageState> { { mini_insert_output( &mut state, - OutPoint { - txid: hex_hash(format!("iuna-genesis-allocation:{address}")), - index: 0, - }, + genesis_allocation_outpoint(&snapshot.launch_profile.profile_id, address), TxOutput { address: address.clone(), amount: *amount, diff --git a/src/domain/genesis.rs b/src/domain/genesis.rs @@ -3,8 +3,9 @@ use std::collections::BTreeMap; use anyhow::{Result, bail}; use super::{ - Amount, BLOCK_REWARD, Block, BurnBundleSection, FinalizerMode, OutPoint, Transaction, TxOutput, - apply_transaction, credit_reward_output, hex_hash, validate_genesis_burn_transaction, + Amount, BLOCK_REWARD, Block, BurnBundleSection, FinalizerMode, OutPoint, Transaction, + TransactionSigningDomain, TxOutput, apply_transaction, credit_reward_output, hex_hash, + validate_genesis_burn_transaction, }; pub(super) fn build_genesis_block( @@ -41,13 +42,15 @@ pub(super) fn build_genesis_block( pub(super) fn utxos_after_genesis( genesis_allocations: &BTreeMap<String, Amount>, genesis: &Block, + chain_id: &str, ) -> Result<BTreeMap<OutPoint, TxOutput>> { - let mut utxos = genesis_allocation_utxos(genesis_allocations); + let mut utxos = genesis_allocation_utxos(genesis_allocations, chain_id); + let signing_domain = TransactionSigningDomain::new(chain_id, genesis.hash.clone()); for transaction in &genesis.transactions { match transaction { Transaction::Burn { .. } => { validate_genesis_burn_transaction(transaction)?; - apply_transaction(transaction, &mut utxos)?; + apply_transaction(transaction, &mut utxos, &signing_domain)?; } Transaction::Transfer { .. } | Transaction::Mine { .. } => { bail!("genesis only supports burn transactions") @@ -60,13 +63,14 @@ pub(super) fn utxos_after_genesis( fn genesis_allocation_utxos( genesis_allocations: &BTreeMap<String, Amount>, + chain_id: &str, ) -> BTreeMap<OutPoint, TxOutput> { genesis_allocations .iter() .filter(|(_, amount)| **amount > 0) .map(|(address, amount)| { ( - genesis_allocation_outpoint(address), + genesis_allocation_outpoint(chain_id, address), TxOutput { address: address.clone(), amount: *amount, @@ -87,9 +91,14 @@ pub(super) fn balances_from_utxos( balances } -pub(super) fn genesis_allocation_outpoint(address: &str) -> OutPoint { +pub(crate) fn genesis_allocation_outpoint(chain_id: &str, address: &str) -> OutPoint { + let mut payload = b"IUNA-GENESIS-ALLOCATION".to_vec(); + payload.extend_from_slice(&(chain_id.len() as u64).to_be_bytes()); + payload.extend_from_slice(chain_id.as_bytes()); + payload.extend_from_slice(&(address.len() as u64).to_be_bytes()); + payload.extend_from_slice(address.as_bytes()); OutPoint { - txid: hex_hash(format!("iuna-genesis-allocation:{address}")), + txid: hex_hash(payload), index: 0, } } @@ -165,10 +174,17 @@ mod tests { let bob = Wallet::from_seed("genesis-outpoint-bob"); assert_ne!( - genesis_allocation_outpoint(alice.address()), - genesis_allocation_outpoint(bob.address()) + genesis_allocation_outpoint("chain-a", alice.address()), + genesis_allocation_outpoint("chain-a", bob.address()) + ); + assert_ne!( + genesis_allocation_outpoint("chain-a", alice.address()), + genesis_allocation_outpoint("chain-b", alice.address()) + ); + assert_eq!( + genesis_allocation_outpoint("chain-a", alice.address()).index, + 0 ); - assert_eq!(genesis_allocation_outpoint(alice.address()).index, 0); } #[test] diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -74,6 +74,7 @@ impl Ledger { let mut utxo_lineage = self.utxo_lineage.clone(); let mut lineage_values = self.lineage_values.clone(); let mut lineage_owners = self.lineage_owners.clone(); + let signing_domain = self.transaction_signing_domain(); let mut signatures = BTreeSet::new(); for tx in &block.transactions { if !signatures.insert(tx.signature()) { @@ -87,6 +88,7 @@ impl Ledger { &mut utxo_lineage, &mut lineage_values, &mut lineage_owners, + &signing_domain, )?; } let expected_reward = block_reward(&block.transactions, 0)?; @@ -259,8 +261,9 @@ fn apply_transaction_with_lineage( utxo_lineage: &mut std::collections::BTreeMap<super::OutPoint, super::UtxoLineageRoot>, lineage_values: &mut std::collections::BTreeMap<super::UtxoLineageRoot, Amount>, lineage_owners: &mut super::LineageOwnerValues, + signing_domain: &super::TransactionSigningDomain, ) -> Result<()> { - transaction.verify_signature()?; + transaction.verify_signature(signing_domain)?; if matches!(transaction, Transaction::Mine { .. }) { let output = transaction.outputs().remove(0); ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -43,7 +43,7 @@ impl Ledger { outputs, fee, } - .sign(wallet); + .sign(wallet, &self.transaction_signing_domain())?; self.validate_new_transaction(&transaction)?; Ok(transaction) } @@ -81,7 +81,7 @@ impl Ledger { outputs, fee, } - .sign(wallet); + .sign(wallet, &self.transaction_signing_domain())?; self.validate_new_transaction(&transaction)?; Ok(transaction) } @@ -137,7 +137,7 @@ impl Ledger { amount, fee, } - .sign(wallet); + .sign(wallet, &self.transaction_signing_domain())?; self.validate_new_transaction(&transaction)?; Ok(transaction) } @@ -148,8 +148,16 @@ impl Ledger { let anchor = self.tip().hash.clone(); let salt = 1; let difficulty_bits = self.current_mine_difficulty_bits(); + let signing_domain = self.transaction_signing_domain(); for nonce in 0..u64::MAX { - let signature = mine_signature(&recipient, &anchor, salt, nonce, difficulty_bits); + let signature = mine_signature( + &signing_domain, + &recipient, + &anchor, + salt, + nonce, + difficulty_bits, + )?; if !hash_meets_difficulty(&signature, difficulty_bits) { continue; } @@ -182,10 +190,18 @@ impl Ledger { validate_address(&recipient, "mine recipient")?; let anchor = self.tip().hash.clone(); let difficulty_bits = self.current_mine_difficulty_bits(); + let signing_domain = self.transaction_signing_domain(); let mut attempts = 0_u64; let mut nonce = start_nonce; while attempts < max_attempts { - let signature = mine_signature(&recipient, &anchor, salt, nonce, difficulty_bits); + let signature = mine_signature( + &signing_domain, + &recipient, + &anchor, + salt, + nonce, + difficulty_bits, + )?; attempts = attempts.saturating_add(1); let next_nonce = nonce.checked_add(1).unwrap_or(0); if hash_meets_difficulty(&signature, difficulty_bits) { @@ -223,7 +239,13 @@ impl Ledger { salt: u64, difficulty_bits: u32, ) -> Result<StratumMineTemplate> { - stratum_mine_template(recipient, anchor.as_ref(), salt, difficulty_bits) + stratum_mine_template( + &self.transaction_signing_domain(), + recipient, + anchor.as_ref(), + salt, + difficulty_bits, + ) } pub fn build_stratum_mine( @@ -233,6 +255,7 @@ impl Ledger { ) -> Result<Transaction> { let nonce = super::stratum::pack_stratum_nonce(share.extranonce2, share.header_nonce); let header = stratum_mine_header_bytes( + &self.transaction_signing_domain(), &template.recipient, &template.anchor, template.salt, diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs @@ -50,7 +50,12 @@ impl Ledger { .get(&burn.from) .copied() .unwrap_or_default(); - Transaction::genesis_burn_with_allocation(burn.from, burn.amount, allocation) + Transaction::genesis_burn_with_allocation( + burn.from, + burn.amount, + allocation, + &launch_profile.profile_id, + ) }) .collect::<Result<Vec<_>>>()?; Self::new_with_genesis_transactions( @@ -69,7 +74,8 @@ impl Ledger { ) -> Result<Self> { validate_genesis_allocations(&genesis_allocations)?; let genesis = build_genesis_block(&genesis_allocations, genesis_transactions); - let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; + let utxos = + utxos_after_genesis(&genesis_allocations, &genesis, &launch_profile.profile_id)?; let tickets = genesis_tickets(&genesis_allocations, &genesis, &launch_profile)?; let compact_block_context = if genesis_allocations.is_empty() { CompactBlockContext::default() @@ -136,7 +142,8 @@ impl Ledger { if genesis != expected_genesis { bail!("chain snapshot genesis does not match its allocations and transactions"); } - let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; + let utxos = + utxos_after_genesis(&genesis_allocations, &genesis, &launch_profile.profile_id)?; let compact_block_context = CompactBlockContext::for_chain(&genesis_allocations, std::slice::from_ref(&genesis))?; @@ -327,8 +334,25 @@ fn fork_rewrites_finalized_history(local_height: u64, common_ancestor_height: u6 #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::fork_rewrites_finalized_history; - use crate::domain::FORK_FINALITY_DEPTH; + use crate::domain::{ + FORK_FINALITY_DEPTH, LaunchProfile, Ledger, StratumMineShare, Transaction, Wallet, + }; + + fn profile(profile_id: &str) -> LaunchProfile { + LaunchProfile { + profile_id: profile_id.to_string(), + mine_difficulty_bits: 0, + ..LaunchProfile::default() + } + } + + fn ledger_with_profile(allocations: BTreeMap<String, u64>, profile_id: &str) -> Ledger { + Ledger::new_with_genesis_burns_and_profile(allocations, Vec::new(), 1, profile(profile_id)) + .unwrap() + } #[test] fn forks_may_rewrite_six_blocks_but_not_seven() { @@ -337,4 +361,174 @@ mod tests { assert!(fork_rewrites_finalized_history(100, 93)); assert!(!fork_rewrites_finalized_history(5, 0)); } + + #[test] + fn transfer_and_burn_signatures_cannot_replay_between_chain_ids() { + let alice = Wallet::from_seed("chain-replay-alice"); + let bob = Wallet::from_seed("chain-replay-bob"); + let allocations = BTreeMap::from([(alice.address().to_string(), 100)]); + let chain_ids = [ + "iuna-mainnet-candidate", + "iuna-mainnet-v1", + "iuna-testnet-v1", + ]; + + for foreign_chain_id in &chain_ids[1..] { + let source = ledger_with_profile(allocations.clone(), chain_ids[0]); + let mut foreign = ledger_with_profile(allocations.clone(), foreign_chain_id); + assert_eq!(source.genesis_hash(), foreign.genesis_hash()); + assert_ne!(source.utxos, foreign.utxos); + + let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap(); + let transfer_error = foreign.submit_transaction(transfer).unwrap_err(); + assert!( + transfer_error + .to_string() + .contains("transaction signature is invalid") + ); + + let burn = source.build_burn(&alice, 10, 1).unwrap(); + let burn_error = foreign.submit_transaction(burn).unwrap_err(); + assert!( + burn_error + .to_string() + .contains("transaction signature is invalid") + ); + } + } + + #[test] + fn signatures_cannot_replay_between_distinct_genesis_hashes() { + let alice = Wallet::from_seed("genesis-replay-alice"); + let bob = Wallet::from_seed("genesis-replay-bob"); + let base_allocations = BTreeMap::from([(alice.address().to_string(), 100)]); + let other_allocations = BTreeMap::from([ + (alice.address().to_string(), 100), + (bob.address().to_string(), 1), + ]); + let source = ledger_with_profile(base_allocations, "same-chain-id"); + let mut foreign = ledger_with_profile(other_allocations, "same-chain-id"); + assert_ne!(source.genesis_hash(), foreign.genesis_hash()); + + let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap(); + let error = foreign.submit_transaction(transfer).unwrap_err(); + + assert!( + error + .to_string() + .contains("transaction signature is invalid") + ); + } + + #[test] + fn mine_proofs_cannot_replay_between_chain_ids() { + let miner = Wallet::from_seed("mine-replay-miner"); + let allocations = BTreeMap::from([(miner.address().to_string(), 100)]); + let source = ledger_with_profile(allocations.clone(), "mine-chain-a"); + let mut foreign = ledger_with_profile(allocations, "mine-chain-b"); + assert_eq!(source.genesis_hash(), foreign.genesis_hash()); + + let mine = source.build_mine(miner.address()).unwrap(); + let error = foreign.submit_transaction(mine).unwrap_err(); + + assert!( + error + .to_string() + .contains("mine transaction proof hash is invalid") + ); + } + + #[test] + fn stratum_mine_proofs_cannot_replay_between_chain_ids() { + let miner = Wallet::from_seed("stratum-replay-miner"); + let allocations = BTreeMap::from([(miner.address().to_string(), 100)]); + let source = ledger_with_profile(allocations.clone(), "stratum-chain-a"); + let mut foreign = ledger_with_profile(allocations, "stratum-chain-b"); + let template = source + .stratum_mine_template( + miner.address(), + source.genesis_hash(), + 7, + source.current_mine_difficulty_bits(), + ) + .unwrap(); + let mine = source + .build_stratum_mine( + template, + StratumMineShare { + extranonce2: [0; 4], + header_nonce: [0; 4], + }, + ) + .unwrap(); + + let error = foreign.submit_transaction(mine).unwrap_err(); + + assert!(error.to_string().contains("proof header is invalid")); + } + + #[test] + fn transaction_hex_casing_cannot_be_malleated_after_signing() { + let alice = Wallet::from_seed("hex-malleability-alice"); + let bob = Wallet::from_seed("hex-malleability-bob"); + let ledger = ledger_with_profile( + BTreeMap::from([(alice.address().to_string(), 100)]), + "hex-malleability-chain", + ); + let transaction = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap(); + + let reject = |mutated| { + let error = ledger + .clone() + .submit_transaction(mutated) + .expect_err("noncanonical transaction hex must be rejected"); + assert!( + format!("{error:#}").contains("canonical lowercase"), + "unexpected rejection: {error:#}" + ); + }; + + let mut recipient = transaction.clone(); + let Transaction::Transfer { outputs, .. } = &mut recipient else { + unreachable!() + }; + outputs[0].address.make_ascii_uppercase(); + reject(recipient); + + let mut change = transaction.clone(); + let Transaction::Transfer { outputs, .. } = &mut change else { + unreachable!() + }; + outputs[1].address.make_ascii_uppercase(); + reject(change); + + let mut owner = transaction.clone(); + let Transaction::Transfer { inputs, .. } = &mut owner else { + unreachable!() + }; + inputs[0].owner.make_ascii_uppercase(); + reject(owner); + + let mut outpoint = transaction.clone(); + let Transaction::Transfer { inputs, .. } = &mut outpoint else { + unreachable!() + }; + inputs[0].outpoint.txid.make_ascii_uppercase(); + reject(outpoint); + + let mut signature = transaction; + let Transaction::Transfer { + inputs, + signature: transaction_signature, + .. + } = &mut signature + else { + unreachable!() + }; + transaction_signature.make_ascii_uppercase(); + for input in inputs { + input.signature.make_ascii_uppercase(); + } + reject(signature); + } } diff --git a/src/domain/ledger_mempool.rs b/src/domain/ledger_mempool.rs @@ -44,7 +44,8 @@ impl Ledger { return Ok(TransactionSubmitOutcome::AlreadyKnown); } - transaction.verify_signature()?; + let signing_domain = self.transaction_signing_domain(); + transaction.verify_signature(&signing_domain)?; self.validate_transaction_terms(&transaction)?; ensure_transaction_fits_empty_block( compact_block_context(self), @@ -79,7 +80,7 @@ impl Ledger { self.orphan_bytes = self.orphan_bytes.saturating_add(candidate_bytes); return Ok(TransactionSubmitOutcome::Added); } - apply_transaction(&transaction, &mut utxos)?; + apply_transaction(&transaction, &mut utxos, &signing_domain)?; let candidate_bytes = ensure_pending_pool_bytes( "mempool", self.pending_bytes, @@ -223,7 +224,8 @@ mod tests { }], fee: 1, } - .sign(&wallet); + .sign(&wallet, &ledger.transaction_signing_domain()) + .unwrap(); ledger.orphans = vec![dummy_mine('e'); MAX_ORPHAN_TRANSACTIONS]; assert_eq!(ledger.orphans.len(), 1_024); diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -270,8 +270,9 @@ pub(super) fn vdf_content_commitment( pub(super) fn apply_transaction( transaction: &Transaction, utxos: &mut BTreeMap<OutPoint, TxOutput>, + signing_domain: &super::TransactionSigningDomain, ) -> Result<()> { - transaction.verify_signature()?; + transaction.verify_signature(signing_domain)?; match transaction { Transaction::Mine { recipient, .. } => { let output = TxOutput { @@ -372,11 +373,12 @@ pub(super) fn spend_inputs( pub(super) fn apply_spendable_pending_transaction( transaction: &Transaction, utxos: &mut BTreeMap<OutPoint, TxOutput>, + signing_domain: &super::TransactionSigningDomain, ) -> Result<()> { if matches!(transaction, Transaction::Mine { .. }) { bail!("pending mine outputs are not spendable"); } - transaction.verify_signature()?; + transaction.verify_signature(signing_domain)?; ensure_single_input_owner(transaction)?; let input_total = transaction_input_total(transaction, utxos)?; let outputs = transaction.outputs(); @@ -612,6 +614,7 @@ pub(super) fn best_selectable_transaction_index( transactions: &[Transaction], utxos: &BTreeMap<OutPoint, TxOutput>, required_kind: Option<TransactionKind>, + signing_domain: &super::TransactionSigningDomain, ) -> Option<usize> { transactions .iter() @@ -622,7 +625,7 @@ pub(super) fn best_selectable_transaction_index( }) .filter(|(_, tx)| { let mut utxos = utxos.clone(); - apply_transaction(tx, &mut utxos).is_ok() + apply_transaction(tx, &mut utxos, signing_domain).is_ok() }) .max_by(|(_, left), (_, right)| { fee_rate_key(left) @@ -638,6 +641,7 @@ pub(super) fn best_selectable_burn_from_index( transactions: &[Transaction], utxos: &BTreeMap<OutPoint, TxOutput>, owner: &str, + signing_domain: &super::TransactionSigningDomain, ) -> Option<usize> { transactions .iter() @@ -645,7 +649,7 @@ pub(super) fn best_selectable_burn_from_index( .filter(|(_, tx)| tx.is_burn() && tx.sender() == owner) .filter(|(_, tx)| { let mut utxos = utxos.clone(); - apply_transaction(tx, &mut utxos).is_ok() + apply_transaction(tx, &mut utxos, signing_domain).is_ok() }) .max_by(|(_, left), (_, right)| { fee_rate_key(left) diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs @@ -29,6 +29,7 @@ pub(crate) const MINE_ANCHOR_LIMIT_REACHED: &str = "mine transaction anchor limi impl Ledger { pub(super) fn valid_pending_transactions(&self) -> Vec<Transaction> { let mut utxos = self.utxos.clone(); + let signing_domain = self.transaction_signing_domain(); let mut valid = Vec::new(); let mut remaining = self.pending.iter().collect::<Vec<_>>(); let mut selected_mine_anchor_counts = BTreeMap::new(); @@ -52,7 +53,7 @@ impl Ledger { } if transaction_inputs_available(tx, &utxos) && self.validate_transaction_terms(tx).is_ok() - && apply_transaction(tx, &mut utxos).is_ok() + && apply_transaction(tx, &mut utxos, &signing_domain).is_ok() { if let Some(anchor) = mine_anchor(tx) { selected_mine_anchor_counts @@ -110,6 +111,7 @@ impl Ledger { burn_bundle_section: &BurnBundleSection, ) -> Result<BlockSelection> { let block_context = compact_block_context(self); + let signing_domain = self.transaction_signing_domain(); let mut utxos = self.utxos.clone(); let mut remaining = self.valid_pending_transactions(); let mut selected = Vec::new(); @@ -129,9 +131,14 @@ impl Ledger { .with_context(|| format!("required burn {signature} is not pending"))?, ) } else if let Some(owner) = required_burn_owner { - best_selectable_burn_from_index(&remaining, &utxos, owner) + best_selectable_burn_from_index(&remaining, &utxos, owner, &signing_domain) } else { - best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) + best_selectable_transaction_index( + &remaining, + &utxos, + Some(TransactionKind::Burn), + &signing_domain, + ) }; if let Some(index) = anchor_index { let tx = remaining.remove(index); @@ -158,7 +165,8 @@ impl Ledger { bail!("attested burns do not fit within the block transaction count limit"); } let signature = tx.signature().to_string(); - apply_transaction(&tx, &mut utxos).context("attested burn is not spendable")?; + apply_transaction(&tx, &mut utxos, &signing_domain) + .context("attested burn is not spendable")?; selected.push(tx); selected_required_burn_signatures.insert(signature); } @@ -184,7 +192,9 @@ impl Ledger { } while selected.len() < self.launch_profile.max_block_transactions { - let Some(index) = best_selectable_transaction_index(&remaining, &utxos, None) else { + let Some(index) = + best_selectable_transaction_index(&remaining, &utxos, None, &signing_domain) + else { break; }; let tx = remaining.remove(index); @@ -199,7 +209,7 @@ impl Ledger { burn_bundle_section, )? <= self.launch_profile.max_block_bytes { - apply_transaction(&tx, &mut utxos)?; + apply_transaction(&tx, &mut utxos, &signing_domain)?; selected.push(tx); } } @@ -226,7 +236,8 @@ impl Ledger { if selected.len() >= self.launch_profile.max_block_transactions { bail!("required block anchor burn does not fit within the transaction count limit"); } - apply_transaction(&tx, utxos).context("required block anchor burn is not spendable")?; + apply_transaction(&tx, utxos, &self.transaction_signing_domain()) + .context("required block anchor burn is not spendable")?; selected.push(tx); Ok(()) } @@ -303,7 +314,7 @@ impl Ledger { )?; self.validate_mine_anchor_available(transaction)?; let mut utxos = self.utxos_after_spendable_pending()?; - apply_transaction(transaction, &mut utxos) + apply_transaction(transaction, &mut utxos, &self.transaction_signing_domain()) } pub(super) fn validate_mine_anchor_available(&self, transaction: &Transaction) -> Result<()> { @@ -332,6 +343,7 @@ impl Ledger { } pub(super) fn promote_orphan_transactions(&mut self) -> Result<()> { + let signing_domain = self.transaction_signing_domain(); loop { if self.pending.len() >= MAX_PENDING_TRANSACTIONS { return Ok(()); @@ -346,7 +358,7 @@ impl Ledger { continue; } if self.validate_new_transaction(transaction).is_ok() - && apply_transaction(transaction, &mut utxos).is_ok() + && apply_transaction(transaction, &mut utxos, &signing_domain).is_ok() { let transaction_bytes = pending_pool_item_bytes(transaction)?; let promoted_bytes = self @@ -436,19 +448,21 @@ impl Ledger { pub(super) fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { let mut utxos = self.utxos.clone(); + let signing_domain = self.transaction_signing_domain(); for pending in self.valid_pending_transactions() { - apply_transaction(&pending, &mut utxos)?; + apply_transaction(&pending, &mut utxos, &signing_domain)?; } Ok(utxos) } pub(super) fn utxos_after_spendable_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { let mut utxos = self.utxos.clone(); + let signing_domain = self.transaction_signing_domain(); for pending in self.valid_pending_transactions() { if matches!(pending, Transaction::Mine { .. }) { continue; } - if apply_spendable_pending_transaction(&pending, &mut utxos).is_err() { + if apply_spendable_pending_transaction(&pending, &mut utxos, &signing_domain).is_err() { continue; } } diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -402,6 +402,13 @@ impl Ledger { &self.chain[0].hash } + pub(super) fn transaction_signing_domain(&self) -> super::TransactionSigningDomain { + super::TransactionSigningDomain::new( + self.launch_profile.profile_id.clone(), + self.genesis_hash().to_string(), + ) + } + pub fn is_setup_placeholder(&self) -> bool { self.height() == 0 && self.genesis_allocations.is_empty() diff --git a/src/domain/mining.rs b/src/domain/mining.rs @@ -1,4 +1,6 @@ -use super::hex_hash; +use anyhow::Result; + +use super::{TransactionSigningDomain, hex_hash, mine_signing_bytes}; pub(super) fn mine_payload( recipient: &str, @@ -11,33 +13,46 @@ pub(super) fn mine_payload( } pub(super) fn mine_signature( + domain: &TransactionSigningDomain, recipient: &str, anchor: &str, salt: u64, nonce: u64, difficulty_bits: u32, -) -> String { - hex_hash(mine_payload( +) -> Result<String> { + Ok(hex_hash(mine_signing_bytes( + domain, recipient, anchor, salt, nonce, difficulty_bits, - )) + )?)) } #[cfg(test)] mod tests { use super::{mine_payload, mine_signature}; + use crate::domain::{TransactionSigningDomain, Wallet}; #[test] - fn mine_signature_hashes_canonical_mine_payload() { + fn mine_signature_commits_binary_chain_domain() { let payload = mine_payload("recipient", "anchor", 1, 2, 12); assert_eq!(payload, "iuna-mine:recipient:anchor:1:2:12"); - assert_eq!( - mine_signature("recipient", "anchor", 1, 2, 12), - "47f9ad353685fdb9b4932cefa9dd1d27f8af70e27eaedf15c4f9ffbbb64300a3" + let wallet = Wallet::from_seed("mine-signature-recipient"); + let domain = TransactionSigningDomain::new("test-chain", "0".repeat(64)); + assert_ne!( + mine_signature(&domain, wallet.address(), &"1".repeat(64), 1, 2, 12).unwrap(), + mine_signature( + &TransactionSigningDomain::new("other-chain", "0".repeat(64)), + wallet.address(), + &"1".repeat(64), + 1, + 2, + 12, + ) + .unwrap() ); } } diff --git a/src/domain/stratum.rs b/src/domain/stratum.rs @@ -2,8 +2,8 @@ use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; use super::{ - HASH_BYTES, decode_hex_array, hex_encode, - validation::{validate_address, validate_hash}, + HASH_BYTES, TransactionSigningDomain, hex_encode, mine_signing_bytes, + validation::{decode_canonical_hex_array, validate_address, validate_hash}, }; pub const STRATUM_EXTRANONCE1_HEX: &str = "00000000"; @@ -52,26 +52,28 @@ fn unpack_stratum_nonce(nonce: u64) -> ([u8; 4], [u8; 4]) { } fn stratum_coinbase_prefix( + domain: &TransactionSigningDomain, recipient: &str, anchor: &str, salt: u64, difficulty_bits: u32, -) -> Vec<u8> { - format!("iuna-stratum-mine:{recipient}:{anchor}:{salt}:{difficulty_bits}:").into_bytes() +) -> Result<Vec<u8>> { + mine_signing_bytes(domain, recipient, anchor, salt, 0, difficulty_bits) } fn stratum_coinbase_bytes( + domain: &TransactionSigningDomain, recipient: &str, anchor: &str, salt: u64, nonce: u64, difficulty_bits: u32, -) -> Vec<u8> { +) -> Result<Vec<u8>> { let (extranonce2, _) = unpack_stratum_nonce(nonce); - let mut coinbase = stratum_coinbase_prefix(recipient, anchor, salt, difficulty_bits); + let mut coinbase = stratum_coinbase_prefix(domain, recipient, anchor, salt, difficulty_bits)?; coinbase.extend_from_slice(&[0, 0, 0, 0]); coinbase.extend_from_slice(&extranonce2); - coinbase + Ok(coinbase) } fn double_sha256(bytes: &[u8]) -> [u8; 32] { @@ -81,6 +83,7 @@ fn double_sha256(bytes: &[u8]) -> [u8; 32] { } pub(super) fn stratum_mine_header_bytes( + domain: &TransactionSigningDomain, recipient: &str, anchor: &str, salt: u64, @@ -89,16 +92,17 @@ pub(super) fn stratum_mine_header_bytes( ) -> Result<[u8; 80]> { let mut header = [0_u8; STRATUM_MINE_HEADER_BYTES]; header[0..4].copy_from_slice(&STRATUM_MINE_VERSION); - let anchor_bytes = - decode_hex_array::<HASH_BYTES>(anchor).context("mine transaction anchor is not hex")?; + let anchor_bytes = decode_canonical_hex_array::<HASH_BYTES>(anchor) + .context("mine transaction anchor is not hex")?; header[4..36].copy_from_slice(&anchor_bytes); let merkle_root = double_sha256(&stratum_coinbase_bytes( + domain, recipient, anchor, salt, nonce, difficulty_bits, - )); + )?); header[36..68].copy_from_slice(&merkle_root); header[68..72].copy_from_slice(&STRATUM_MINE_NTIME); header[72..76].copy_from_slice(&difficulty_bits.to_le_bytes()); @@ -114,6 +118,7 @@ pub(super) fn stratum_mine_signature(header: &[u8; 80]) -> String { } pub(super) fn stratum_mine_template( + domain: &TransactionSigningDomain, recipient: impl Into<String>, anchor: &str, salt: u64, @@ -122,14 +127,20 @@ pub(super) fn stratum_mine_template( let recipient = recipient.into(); validate_address(&recipient, "mine recipient")?; validate_hash(anchor, "mine transaction anchor")?; - let anchor_bytes = - decode_hex_array::<HASH_BYTES>(anchor).context("mine transaction anchor is not hex")?; + let anchor_bytes = decode_canonical_hex_array::<HASH_BYTES>(anchor) + .context("mine transaction anchor is not hex")?; Ok(StratumMineTemplate { recipient: recipient.clone(), anchor: anchor.to_string(), salt, difficulty_bits, - coinbase_prefix: stratum_coinbase_prefix(&recipient, anchor, salt, difficulty_bits), + coinbase_prefix: stratum_coinbase_prefix( + domain, + &recipient, + anchor, + salt, + difficulty_bits, + )?, version_hex: hex_encode(STRATUM_MINE_VERSION), prev_hash_hex: hex_encode(anchor_bytes), nbits_hex: hex_encode(difficulty_bits.to_le_bytes()), @@ -167,6 +178,7 @@ mod tests { STRATUM_EXTRANONCE1_HEX, STRATUM_EXTRANONCE2_SIZE, hash_meets_difficulty, pack_stratum_nonce, stratum_mine_header_bytes, }; + use crate::domain::{TransactionSigningDomain, Wallet}; #[test] fn stratum_nonce_packs_extranonce_big_endian_and_header_nonce_little_endian() { @@ -183,7 +195,10 @@ mod tests { #[test] fn stratum_header_rejects_non_hex_anchor() { - let error = stratum_mine_header_bytes("recipient", "not-hex", 0, 0, 12).unwrap_err(); + let wallet = Wallet::from_seed("stratum-invalid-anchor-recipient"); + let domain = TransactionSigningDomain::new("test", "0".repeat(64)); + let error = + stratum_mine_header_bytes(&domain, wallet.address(), "not-hex", 0, 0, 12).unwrap_err(); assert!( error diff --git a/src/domain/transaction.rs b/src/domain/transaction.rs @@ -4,13 +4,40 @@ use anyhow::{Context, Result, bail}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; +use super::validation::{decode_canonical_hex, decode_canonical_hex_array}; use super::{ - Amount, MINE_FINALIZER_FEE, MINE_REWARD, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, Wallet, - canonical_transaction_size_bytes, decode_hex_array, genesis_allocation_outpoint, - hash_meets_difficulty, hex_encode, hex_hash, mine_payload, mine_signature, - stratum_mine_header_bytes, stratum_mine_signature, + Amount, HASH_BYTES, MINE_FINALIZER_FEE, MINE_REWARD, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, Wallet, + canonical_transaction_size_bytes, genesis_allocation_outpoint, hash_meets_difficulty, + hex_encode, hex_hash, mine_payload, mine_signature, stratum_mine_header_bytes, + stratum_mine_signature, }; +pub const TRANSACTION_SIGNING_FORMAT_VERSION: u16 = 1; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct TransactionSigningDomain { + chain_id: String, + genesis_hash: String, +} + +impl TransactionSigningDomain { + pub(super) fn new(chain_id: impl Into<String>, genesis_hash: impl Into<String>) -> Self { + Self { + chain_id: chain_id.into(), + genesis_hash: genesis_hash.into(), + } + } + + pub(super) fn encode(&self, bytes: &mut Vec<u8>) -> Result<()> { + bytes.extend_from_slice(b"IUNA-TX"); + bytes.extend_from_slice(&TRANSACTION_SIGNING_FORMAT_VERSION.to_be_bytes()); + encode_bytes(bytes, self.chain_id.as_bytes(), "chain ID")?; + let genesis_hash = decode_canonical_hex_array::<HASH_BYTES>(&self.genesis_hash) + .context("transaction signing genesis hash is invalid")?; + encode_bytes(bytes, &genesis_hash, "genesis hash") + } +} + #[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub struct OutPoint { pub txid: String, @@ -71,13 +98,19 @@ pub struct MineSearchOutcome { impl Transaction { pub fn genesis_burn(from: impl Into<String>, amount: Amount) -> Self { let from = from.into(); - Self::genesis_burn_with_change(from, amount, Vec::new()) + Self::genesis_burn_with_change( + from, + amount, + Vec::new(), + &super::LaunchProfile::default().profile_id, + ) } pub(super) fn genesis_burn_with_allocation( from: impl Into<String>, amount: Amount, allocation: Amount, + chain_id: &str, ) -> Result<Self> { if amount > allocation { bail!("genesis burn exceeds allocation"); @@ -92,12 +125,19 @@ impl Transaction { } else { Vec::new() }; - Ok(Self::genesis_burn_with_change(from, amount, change)) + Ok(Self::genesis_burn_with_change( + from, amount, change, chain_id, + )) } - fn genesis_burn_with_change(from: String, amount: Amount, change: Vec<TxOutput>) -> Self { + fn genesis_burn_with_change( + from: String, + amount: Amount, + change: Vec<TxOutput>, + chain_id: &str, + ) -> Self { let input = TxInput { - outpoint: genesis_allocation_outpoint(&from), + outpoint: genesis_allocation_outpoint(chain_id, &from), owner: from.clone(), signature: "genesis".to_string(), }; @@ -223,7 +263,7 @@ impl Transaction { } } - pub(super) fn verify_signature(&self) -> Result<()> { + pub(super) fn verify_signature(&self, domain: &TransactionSigningDomain) -> Result<()> { if let Self::Mine { recipient, anchor, @@ -235,15 +275,21 @@ impl Transaction { } = self { let expected = if let Some(proof_header) = proof_header { - let header = - stratum_mine_header_bytes(recipient, anchor, *salt, *nonce, *difficulty_bits)?; + let header = stratum_mine_header_bytes( + domain, + recipient, + anchor, + *salt, + *nonce, + *difficulty_bits, + )?; let expected_header = hex_encode(header); if *proof_header != expected_header { bail!("mine transaction proof header is invalid"); } stratum_mine_signature(&header) } else { - mine_signature(recipient, anchor, *salt, *nonce, *difficulty_bits) + mine_signature(domain, recipient, anchor, *salt, *nonce, *difficulty_bits)? }; if *signature != expected { bail!("mine transaction proof hash is invalid"); @@ -264,15 +310,15 @@ impl Transaction { bail!("transaction input signature does not match transaction signature"); } let sender = self.sender(); - let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(sender) + let public_key = decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(sender) .with_context(|| format!("invalid public key for {sender}"))?; - let signature = decode_hex_array::<SIGNATURE_BYTES>(self.signature()) + let signature = decode_canonical_hex_array::<SIGNATURE_BYTES>(self.signature()) .context("invalid signature hex")?; let verifying_key = VerifyingKey::from_bytes(&public_key).context("invalid transaction public key")?; let signature = Signature::from_bytes(&signature); verifying_key - .verify(self.signing_payload().as_bytes(), &signature) + .verify(&self.signing_bytes(domain)?, &signature) .context("transaction signature is invalid") } @@ -299,6 +345,36 @@ impl Transaction { .iter() .all(|input| input.signature == "genesis") } + + fn signing_bytes(&self, domain: &TransactionSigningDomain) -> Result<Vec<u8>> { + match self { + Self::Transfer { + inputs, + outputs, + fee, + .. + } => UnsignedUtxoTransaction::Transfer { + inputs: unsigned_inputs(inputs), + outputs: outputs.clone(), + fee: *fee, + } + .signing_bytes(domain), + Self::Burn { + inputs, + change, + amount, + fee, + .. + } => UnsignedUtxoTransaction::Burn { + inputs: unsigned_inputs(inputs), + change: change.clone(), + amount: *amount, + fee: *fee, + } + .signing_bytes(domain), + Self::Mine { .. } => unreachable!("mine transactions use proof hashes"), + } + } } impl TxInput { @@ -338,8 +414,12 @@ pub(super) enum UnsignedUtxoTransaction { } impl UnsignedUtxoTransaction { - pub(super) fn sign(self, wallet: &Wallet) -> Transaction { - let signature = wallet.sign_payload(&self.canonical()); + pub(super) fn sign( + self, + wallet: &Wallet, + domain: &TransactionSigningDomain, + ) -> Result<Transaction> { + let signature = wallet.sign_bytes(&self.signing_bytes(domain)?); let signed_inputs = self .inputs() .iter() @@ -349,7 +429,7 @@ impl UnsignedUtxoTransaction { signature: signature.clone(), }) .collect::<Vec<_>>(); - match self { + Ok(match self { Self::Transfer { outputs, fee, .. } => Transaction::Transfer { inputs: signed_inputs, outputs, @@ -368,7 +448,7 @@ impl UnsignedUtxoTransaction { fee, signature, }, - } + }) } fn inputs(&self) -> &[UnsignedTxInput] { @@ -400,6 +480,99 @@ impl UnsignedUtxoTransaction { ), } } + + pub(super) fn signing_bytes(&self, domain: &TransactionSigningDomain) -> Result<Vec<u8>> { + let mut bytes = Vec::new(); + domain.encode(&mut bytes)?; + match self { + Self::Transfer { + inputs, + outputs, + fee, + } => { + bytes.push(1); + encode_inputs(&mut bytes, inputs)?; + encode_outputs(&mut bytes, outputs)?; + bytes.extend_from_slice(&fee.to_be_bytes()); + } + Self::Burn { + inputs, + change, + amount, + fee, + } => { + bytes.push(2); + encode_inputs(&mut bytes, inputs)?; + encode_outputs(&mut bytes, change)?; + bytes.extend_from_slice(&amount.to_be_bytes()); + bytes.extend_from_slice(&fee.to_be_bytes()); + } + } + Ok(bytes) + } +} + +fn encode_inputs(bytes: &mut Vec<u8>, inputs: &[UnsignedTxInput]) -> Result<()> { + encode_len(bytes, inputs.len(), "input count")?; + for input in inputs { + let txid = decode_canonical_hex(&input.outpoint.txid) + .context("transaction input txid is invalid")?; + if txid.len() != HASH_BYTES && txid.len() != SIGNATURE_BYTES { + bail!("transaction input txid must be a hash or signature"); + } + encode_bytes(bytes, &txid, "input txid")?; + bytes.extend_from_slice(&input.outpoint.index.to_be_bytes()); + let owner = decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(&input.owner) + .context("transaction input owner is invalid")?; + encode_bytes(bytes, &owner, "input owner")?; + } + Ok(()) +} + +fn encode_outputs(bytes: &mut Vec<u8>, outputs: &[TxOutput]) -> Result<()> { + encode_len(bytes, outputs.len(), "output count")?; + for output in outputs { + let address = decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(&output.address) + .context("transaction output address is invalid")?; + encode_bytes(bytes, &address, "output address")?; + bytes.extend_from_slice(&output.amount.to_be_bytes()); + } + Ok(()) +} + +fn encode_bytes(bytes: &mut Vec<u8>, value: &[u8], label: &str) -> Result<()> { + encode_len(bytes, value.len(), label)?; + bytes.extend_from_slice(value); + Ok(()) +} + +fn encode_len(bytes: &mut Vec<u8>, len: usize, label: &str) -> Result<()> { + let len = u32::try_from(len).with_context(|| format!("{label} exceeds u32 length"))?; + bytes.extend_from_slice(&len.to_be_bytes()); + Ok(()) +} + +pub(super) fn mine_signing_bytes( + domain: &TransactionSigningDomain, + recipient: &str, + anchor: &str, + salt: u64, + nonce: u64, + difficulty_bits: u32, +) -> Result<Vec<u8>> { + let mut bytes = Vec::new(); + domain.encode(&mut bytes)?; + bytes.push(3); + let recipient = decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(recipient) + .context("mine recipient is invalid")?; + encode_bytes(&mut bytes, &recipient, "mine recipient")?; + let anchor = + decode_canonical_hex_array::<HASH_BYTES>(anchor).context("mine anchor is invalid")?; + encode_bytes(&mut bytes, &anchor, "mine anchor")?; + bytes.extend_from_slice(&salt.to_be_bytes()); + bytes.extend_from_slice(&nonce.to_be_bytes()); + bytes.extend_from_slice(&difficulty_bits.to_be_bytes()); + Ok(bytes) } pub(super) fn unsigned_inputs(inputs: &[TxInput]) -> Vec<UnsignedTxInput> { @@ -454,3 +627,92 @@ pub(super) fn transaction_inputs_available( .iter() .all(|input| utxos.contains_key(&input.outpoint)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signing_format_v1_has_a_stable_typed_binary_vector() { + let transaction = UnsignedUtxoTransaction::Transfer { + inputs: vec![UnsignedTxInput { + outpoint: OutPoint { + txid: "11".repeat(32), + index: 7, + }, + owner: "33".repeat(32), + }], + outputs: vec![TxOutput { + address: "44".repeat(32), + amount: 5, + }], + fee: 1, + }; + let domain = TransactionSigningDomain::new("iuna-test-vector", "22".repeat(32)); + + let encoded = hex_encode(transaction.signing_bytes(&domain).unwrap()); + + assert_eq!( + encoded, + concat!( + "49554e412d5458", // IUNA-TX domain tag + "0001", // signing format version + "00000010", + "69756e612d746573742d766563746f72", // chain ID + "00000020", + "2222222222222222222222222222222222222222222222222222222222222222", // genesis hash + "01", // transfer type + "00000001", // input count + "00000020", + "1111111111111111111111111111111111111111111111111111111111111111", // txid + "00000007", // output index + "00000020", + "3333333333333333333333333333333333333333333333333333333333333333", // owner + "00000001", // output count + "00000020", + "4444444444444444444444444444444444444444444444444444444444444444", // address + "0000000000000005", // amount + "0000000000000001", // fee + ) + ); + } + + #[test] + fn legacy_text_signature_is_invalid_under_format_v1() { + let wallet = Wallet::from_seed("legacy-transaction-signature"); + let unsigned = UnsignedUtxoTransaction::Transfer { + inputs: vec![UnsignedTxInput { + outpoint: OutPoint { + txid: "11".repeat(32), + index: 0, + }, + owner: wallet.address().to_string(), + }], + outputs: vec![TxOutput { + address: wallet.address().to_string(), + amount: 9, + }], + fee: 1, + }; + let legacy_signature = wallet.sign_payload(&unsigned.canonical()); + let transaction = Transaction::Transfer { + inputs: vec![TxInput { + outpoint: OutPoint { + txid: "11".repeat(32), + index: 0, + }, + owner: wallet.address().to_string(), + signature: legacy_signature.clone(), + }], + outputs: vec![TxOutput { + address: wallet.address().to_string(), + amount: 9, + }], + fee: 1, + signature: legacy_signature, + }; + let domain = TransactionSigningDomain::new("iuna-mainnet-v1", "22".repeat(32)); + + assert!(transaction.verify_signature(&domain).is_err()); + } +} diff --git a/src/domain/validation.rs b/src/domain/validation.rs @@ -2,39 +2,56 @@ use anyhow::{Context, Result, bail}; use super::{ HASH_BYTES, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, Transaction, TxInput, TxOutput, decode_hex, - decode_hex_array, stratum::STRATUM_MINE_HEADER_BYTES, + hex_encode, stratum::STRATUM_MINE_HEADER_BYTES, }; pub fn validate_address(address: &str, label: &str) -> Result<()> { - decode_hex_array::<PUBLIC_KEY_BYTES>(address) + decode_canonical_hex_array::<PUBLIC_KEY_BYTES>(address) .with_context(|| format!("invalid {label} address"))?; Ok(()) } pub(super) fn validate_hash(hash: &str, label: &str) -> Result<()> { - decode_hex_array::<HASH_BYTES>(hash).with_context(|| format!("invalid {label}"))?; + decode_canonical_hex_array::<HASH_BYTES>(hash).with_context(|| format!("invalid {label}"))?; Ok(()) } pub(super) fn validate_signature(signature: &str, label: &str) -> Result<()> { - decode_hex_array::<SIGNATURE_BYTES>(signature).with_context(|| format!("invalid {label}"))?; + decode_canonical_hex_array::<SIGNATURE_BYTES>(signature) + .with_context(|| format!("invalid {label}"))?; Ok(()) } pub(super) fn validate_stratum_header(header: &str) -> Result<()> { - decode_hex_array::<STRATUM_MINE_HEADER_BYTES>(header) + decode_canonical_hex_array::<STRATUM_MINE_HEADER_BYTES>(header) .context("invalid mine transaction proof header")?; Ok(()) } pub(super) fn validate_protocol_id(value: &str, label: &str) -> Result<()> { - let bytes = decode_hex(value).with_context(|| format!("invalid {label}"))?; + let bytes = decode_canonical_hex(value).with_context(|| format!("invalid {label}"))?; match bytes.len() { HASH_BYTES | SIGNATURE_BYTES => Ok(()), length => bail!("invalid {label}: expected 32 or 64 bytes, got {length}"), } } +pub(super) fn decode_canonical_hex(value: &str) -> Result<Vec<u8>> { + let bytes = decode_hex(value)?; + if hex_encode(&bytes) != value { + bail!("hex must use canonical lowercase encoding"); + } + Ok(bytes) +} + +pub(super) fn decode_canonical_hex_array<const N: usize>(value: &str) -> Result<[u8; N]> { + let bytes = decode_canonical_hex(value)?; + let len = bytes.len(); + bytes + .try_into() + .map_err(|_| anyhow::anyhow!("expected {N} hex bytes, got {len}")) +} + pub(super) fn canonical_transaction_size_bytes(transaction: &Transaction) -> usize { match transaction { Transaction::Transfer { @@ -173,6 +190,15 @@ mod tests { } #[test] + fn validators_reject_noncanonical_uppercase_hex() { + assert!(validate_address(&"AB".repeat(32), "test").is_err()); + assert!(validate_hash(&"AB".repeat(32), "test").is_err()); + assert!(validate_signature(&"AB".repeat(64), "test").is_err()); + assert!(validate_stratum_header(&"AB".repeat(80)).is_err()); + assert!(validate_protocol_id(&"AB".repeat(32), "test").is_err()); + } + + #[test] fn compact_len_uses_base_128_varint_width() { assert_eq!(compact_len(0), 1); assert_eq!(compact_len(127), 1); diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs @@ -30,10 +30,14 @@ impl Wallet { } pub(super) fn sign_payload(&self, payload: &str) -> String { + self.sign_bytes(payload.as_bytes()) + } + + pub(super) fn sign_bytes(&self, payload: &[u8]) -> String { let seed = decode_hex_array::<PUBLIC_KEY_BYTES>(&self.secret).expect("wallet secret is valid hex"); let signing_key = SigningKey::from_bytes(&seed); - let signature: Signature = signing_key.sign(payload.as_bytes()); + let signature: Signature = signing_key.sign(payload); hex_encode(signature.to_bytes()) }