diff --git a/Cargo.lock b/Cargo.lock index 8e0ae00..f95c075 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,6 +298,32 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -354,6 +380,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1058,6 +1090,7 @@ dependencies = [ "argon2", "bs58", "chrono", + "curve25519-dalek", "hex", "serde", "serde_json", @@ -1087,6 +1120,7 @@ dependencies = [ "anyhow", "base64", "bs58", + "curve25519-dalek", "dotenvy", "flume", "hex", @@ -1099,6 +1133,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "sha3", "tempfile", "tokio", "tracing", @@ -1400,6 +1435,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" diff --git a/Cargo.toml b/Cargo.toml index d3c2aa4..46e6208 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ axum = "0.7" base64 = "0.22" bs58 = "0.5" chrono = "0.4" +curve25519-dalek = "4.1" dotenvy = "0.15" flume = "0.11" hex = "0.4" diff --git a/crates/pool-common/Cargo.toml b/crates/pool-common/Cargo.toml index 34e0f47..005f278 100644 --- a/crates/pool-common/Cargo.toml +++ b/crates/pool-common/Cargo.toml @@ -8,6 +8,7 @@ anyhow.workspace = true argon2.workspace = true bs58.workspace = true chrono.workspace = true +curve25519-dalek.workspace = true hex.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/pool-common/src/protocol.rs b/crates/pool-common/src/protocol.rs index aff8ff8..ff1c16d 100644 --- a/crates/pool-common/src/protocol.rs +++ b/crates/pool-common/src/protocol.rs @@ -1,3 +1,4 @@ +use curve25519_dalek::ristretto::CompressedRistretto; use serde::{Deserialize, Serialize}; use sha3::{Digest, Sha3_256}; @@ -137,6 +138,7 @@ fn parse_address_network(address: &str) -> Result, String None }; if let Some(network) = network { + validate_stealth_public_keys(payload)?; Ok(Some(network)) } else { Err("invalid address checksum".to_string()) @@ -148,6 +150,30 @@ fn parse_address_network(address: &str) -> Result, String } } +fn validate_stealth_public_keys(payload: &[u8]) -> Result<(), String> { + if payload.len() != 64 { + return Err("invalid address length".to_string()); + } + + let spend_ok = CompressedRistretto::from_slice(&payload[..32]) + .map_err(|_| "invalid address spend public key".to_string())? + .decompress() + .is_some(); + if !spend_ok { + return Err("invalid address spend public key".to_string()); + } + + let view_ok = CompressedRistretto::from_slice(&payload[32..64]) + .map_err(|_| "invalid address view public key".to_string())? + .decompress() + .is_some(); + if !view_ok { + return Err("invalid address view public key".to_string()); + } + + Ok(()) +} + pub fn parse_hash_hex(v: &str) -> Result<[u8; 32], String> { let trimmed = v.trim(); let raw = hex::decode(trimmed).map_err(|err| match err { @@ -181,9 +207,23 @@ fn address_checksum(payload: &[u8], network_id: &str) -> [u8; 32] { #[cfg(test)] mod tests { use super::*; + use curve25519_dalek::constants::RISTRETTO_BASEPOINT_TABLE; + use curve25519_dalek::scalar::Scalar; + + fn test_address_payload(seed: u8) -> [u8; 64] { + let spend_scalar = Scalar::from_bytes_mod_order([seed.max(1); 32]); + let view_scalar = Scalar::from_bytes_mod_order([seed.wrapping_add(1).max(1); 32]); + let spend_pub = (&spend_scalar * RISTRETTO_BASEPOINT_TABLE).compress(); + let view_pub = (&view_scalar * RISTRETTO_BASEPOINT_TABLE).compress(); + + let mut payload = [0u8; 64]; + payload[..32].copy_from_slice(spend_pub.as_bytes()); + payload[32..].copy_from_slice(view_pub.as_bytes()); + payload + } fn test_miner_address(seed: u8) -> String { - let payload = [seed; 64]; + let payload = test_address_payload(seed); let mut encoded = payload.to_vec(); encoded.extend_from_slice(&address_checksum(&payload, NETWORK_ID_MAINNET)[..4]); bs58::encode(encoded).into_string() @@ -239,7 +279,7 @@ mod tests { Some(AddressNetwork::Mainnet) ); - let payload = [0x22; 64]; + let payload = test_address_payload(0x23); let mut testnet = payload.to_vec(); testnet.extend_from_slice(&address_checksum(&payload, NETWORK_ID_TESTNET)[..4]); let testnet_addr = bs58::encode(testnet).into_string(); @@ -252,7 +292,7 @@ mod tests { #[test] fn network_specific_validation_rejects_cross_network_checksum() { - let payload = [0x55; 64]; + let payload = test_address_payload(0x55); let mut encoded = payload.to_vec(); encoded.extend_from_slice(&address_checksum(&payload, NETWORK_ID_TESTNET)[..4]); let address = bs58::encode(encoded).into_string(); @@ -277,7 +317,7 @@ mod tests { #[test] fn miner_address_rejects_bad_checksum() { - let payload = [0x44; 64]; + let payload = test_address_payload(0x44); let mut encoded = payload.to_vec(); encoded.extend_from_slice(&address_checksum(&payload, NETWORK_ID_MAINNET)[..4]); let last = encoded @@ -288,4 +328,11 @@ mod tests { let err = validate_miner_address(&address).expect_err("must reject"); assert!(err.contains("checksum")); } + + #[test] + fn miner_address_rejects_checksummed_invalid_ristretto_keys() { + let address = "S7YPHt98NDKrUNmFaHa9GQu4XJvRPkTR51bxdE4122UFxfB4cqdFP5R2pkJSrNTQGwmFVmKzKodu7F8XmHjTTx9PNx3i"; + let err = validate_miner_address(address).expect_err("must reject"); + assert!(err.contains("public key")); + } } diff --git a/crates/pool-runtime/Cargo.toml b/crates/pool-runtime/Cargo.toml index acd94a3..52d1107 100644 --- a/crates/pool-runtime/Cargo.toml +++ b/crates/pool-runtime/Cargo.toml @@ -25,4 +25,6 @@ pool-common = { path = "../pool-common" } [dev-dependencies] bs58.workspace = true +curve25519-dalek.workspace = true +sha3.workspace = true tempfile.workspace = true diff --git a/crates/pool-runtime/src/engine.rs b/crates/pool-runtime/src/engine.rs index 784ca78..3abd904 100644 --- a/crates/pool-runtime/src/engine.rs +++ b/crates/pool-runtime/src/engine.rs @@ -2237,6 +2237,7 @@ impl NodeApi for InMemoryNode { #[cfg(test)] mod tests { use super::*; + use crate::test_support::test_miner_address as generated_test_miner_address; use pool_common::pow::{difficulty_to_target, DeterministicTestHasher, PowHasher}; fn cfg() -> Config { @@ -2269,11 +2270,11 @@ mod tests { } fn test_miner_address() -> String { - "3EWAEECjhATNX9CHB9ZUSN6jT9FkhhUF22mQruRtAroUpsvegu5XVeJub2t5hRqufQjkc4QNQcPK1cTnco3DdrvWuEX3W".to_string() + generated_test_miner_address(0x31) } fn other_miner_address() -> String { - "5U1KTTQUPKuk3HPZMJ7wsjCTuHWWQPwV34XpiormLibxekrJNoA3zHcpB4mAPrgpKpUWD7ojpDmd2DvaEb5SGir81hpWR".to_string() + generated_test_miner_address(0x32) } #[test] diff --git a/crates/pool-runtime/src/lib.rs b/crates/pool-runtime/src/lib.rs index 1fc111d..7823062 100644 --- a/crates/pool-runtime/src/lib.rs +++ b/crates/pool-runtime/src/lib.rs @@ -26,6 +26,9 @@ pub mod stats; pub mod store; +#[cfg(test)] +pub(crate) mod test_support; + pub(crate) mod stratum; pub mod telemetry; diff --git a/crates/pool-runtime/src/payout.rs b/crates/pool-runtime/src/payout.rs index d688049..bcdaff6 100644 --- a/crates/pool-runtime/src/payout.rs +++ b/crates/pool-runtime/src/payout.rs @@ -3231,6 +3231,7 @@ mod tests { use super::*; use crate::node::HttpError; use crate::store::PoolStore; + use crate::test_support::test_miner_address; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use base64::Engine as _; use pool_common::db::{DbShare, ShareReplayData}; @@ -3386,9 +3387,7 @@ mod tests { } fn test_address(seed: u8) -> String { - let mut payload = [seed.max(1); 64]; - payload[0] = seed.max(1); - bs58::encode(payload).into_string() + test_miner_address(seed) } fn wallet_output(txid: &str, output_index: u32, amount: u64) -> WalletOutput { diff --git a/crates/pool-runtime/src/runtime.rs b/crates/pool-runtime/src/runtime.rs index 9f60691..e3c70b3 100644 --- a/crates/pool-runtime/src/runtime.rs +++ b/crates/pool-runtime/src/runtime.rs @@ -594,6 +594,7 @@ async fn resolve_expected_address_network( mod tests { use super::validate_pool_fee_destination_config; use crate::config::Config; + use crate::test_support::test_miner_address; #[test] fn pool_fee_destination_is_optional_when_fee_is_disabled() { @@ -630,7 +631,7 @@ mod tests { fn valid_pool_fee_destination_is_accepted() { let cfg = Config { pool_fee_pct: 1.0, - pool_fee_wallet_address: "3EWAEECjhATNX9CHB9ZUSN6jT9FkhhUF22mQruRtAroUpsvegu5XVeJub2t5hRqufQjkc4QNQcPK1cTnco3DdrvWuEX3W".to_string(), + pool_fee_wallet_address: test_miner_address(0x41), ..Config::default() }; assert!(validate_pool_fee_destination_config(&cfg).is_ok()); diff --git a/crates/pool-runtime/src/stratum.rs b/crates/pool-runtime/src/stratum.rs index 3381b5a..3d8391f 100644 --- a/crates/pool-runtime/src/stratum.rs +++ b/crates/pool-runtime/src/stratum.rs @@ -1308,14 +1308,7 @@ mod tests { } fn test_miner_address(seed: u8) -> String { - match seed { - 0x55 => "CAWo991gTpGrbhyPsjoNBpVfFiGmWVMD69q4JX9QrJ1Q8PdFSVPdUDYXwAQQV9DYK3gm2J1r33vZ44Jw5yC6BJcf7wT9w", - 0x61 => "DjvnqBWSFE1MSH3wmFizcYgXgzLMma2ugzqhcgrrjc6v1FDi3Ud4QZ4EU1Vkrrdq1ojeGoiDyFFiMYkfXEdYA2gHHTCEx", - 0x62 => "DsYd923qEB9jAzUVRP3xpGs6tmAzTVbDaEbFj319PiShzegkG4PgKR1sgFFhizvMQY5DYWrRTvs9JAxUZLkVVFrDr2R49", - 0x71 => "Fqp7kaAmxSKMDhpgHGxVrBbgwMkUnLwqpnrZNF9TFMZbqigKWnvxzLQVqocth9CDHV8pc9yPtB2bRY1e6vYoTfRt5jGPt", - _ => panic!("missing test address for seed {seed:#x}"), - } - .to_string() + crate::test_support::test_miner_address(seed) } async fn build_tcp_test_server(cfg: Config) -> (Arc, Arc) { @@ -1724,7 +1717,7 @@ mod tests { Arc::new(RuntimeStartingStore), Arc::new(InMemoryNode::default()), )); - let address = "9w1duuowmepV5Zn7hbEtkTPvoa21onsy584eSciXgSCvJWhbkbK6yaEdM8XKniNdedx1REcUdSYF3Sr9UB9sYSh4iuSvT".to_string(); + let address = test_miner_address(0x72); engine .login( "conn1", diff --git a/crates/pool-runtime/src/test_support.rs b/crates/pool-runtime/src/test_support.rs new file mode 100644 index 0000000..8a14f87 --- /dev/null +++ b/crates/pool-runtime/src/test_support.rs @@ -0,0 +1,29 @@ +use curve25519_dalek::constants::RISTRETTO_BASEPOINT_TABLE; +use curve25519_dalek::scalar::Scalar; +use sha3::{Digest, Sha3_256}; + +const STEALTH_ADDRESS_CHECKSUM_TAG: &[u8] = b"blocknet_stealth_address_checksum"; +const NETWORK_ID_MAINNET: &str = "blocknet_mainnet"; + +pub(crate) fn test_miner_address(seed: u8) -> String { + let spend_scalar = Scalar::from_bytes_mod_order([seed.max(1); 32]); + let view_scalar = Scalar::from_bytes_mod_order([seed.wrapping_add(1).max(1); 32]); + let spend_pub = (&spend_scalar * RISTRETTO_BASEPOINT_TABLE).compress(); + let view_pub = (&view_scalar * RISTRETTO_BASEPOINT_TABLE).compress(); + + let mut payload = [0u8; 64]; + payload[..32].copy_from_slice(spend_pub.as_bytes()); + payload[32..].copy_from_slice(view_pub.as_bytes()); + + let mut encoded = payload.to_vec(); + encoded.extend_from_slice(&address_checksum(&payload)[..4]); + bs58::encode(encoded).into_string() +} + +fn address_checksum(payload: &[u8]) -> [u8; 32] { + let mut hasher = Sha3_256::new(); + hasher.update(STEALTH_ADDRESS_CHECKSUM_TAG); + hasher.update(NETWORK_ID_MAINNET.as_bytes()); + hasher.update(payload); + hasher.finalize().into() +}