From 80df9b1e2ed6364a2be2f89031fae085f474a0e1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 04:38:32 +0700 Subject: [PATCH 1/2] fix(key-wallet): derive BLS operator & Ed25519 platform-node keys per DashSync/dashbls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes three independent bugs that made masternode operator (BLS) and platform node (Ed25519) keys diverge from DashSync/dashwallet-ios for the same mnemonic (#878): 1. Hardened BLS child derivation prepended a spurious 0x00 to the HMAC input (secp256k1 BIP32 convention). dashbls uses sk(32) || i(4) || {0,1} with no leading zero, so every hardened level diverged. 2. Non-hardened BLS derivation fed the parent public key into the HMAC in modern/IETF serialization. dashbls serializes it with the legacy flag (fLegacy=true) throughout the HD chain. ExtendedBLSPrivKey/ ExtendedBLSPubKey now use legacy bytes in the HMAC input and expose public_key_bytes_legacy()/to_bytes_legacy(). 3. The default wallet flow derived a secp256k1 BIP32 key at the provider path, reused its secret bytes as the BLS/SLIP-0010 master seed, and applied the provider path again on top. BLSAccount::from_seed / EdDSAAccount::from_seed now take the wallet seed (e.g. the 64-byte BIP39 seed), feed it directly into the target-curve master and apply the account path once; add_bls_account/add_eddsa_account use the wallet's own seed. Seedless wallets (extended-priv-key based, watch- only, external-signable) skip auto-creation of these accounts and require an explicit seed. Also fixes BLSAccount::has_internal_and_external() to false — operator keys live in a single pool (account/i), so seed-based signing derivation (derive_from_seed_private_key_at) now works and yields m/9'/coin'/3'/3'/i. Derivation is pinned by test vectors generated with dashbls (dashpay/bls-signatures @ 0842b17, ExtendedPrivateKey with fLegacy=true) from the standard BIP39 test mnemonic, and SLIP-0010 vectors verified with an independent implementation. Keys previously persisted from these accounts change after this fix — the old ones never corresponded to anything on-chain. Co-Authored-By: Claude Fable 5 --- .../src/account_derivation_tests.rs | 80 +++++++ key-wallet/examples/account_types.rs | 8 +- .../src/account/account_collection_test.rs | 8 +- key-wallet/src/account/bls_account.rs | 28 ++- key-wallet/src/account/eddsa_account.rs | 21 +- key-wallet/src/derivation_bls_bip32.rs | 222 +++++++++++++++++- key-wallet/src/tests/mod.rs | 2 + .../tests/provider_key_derivation_tests.rs | 162 +++++++++++++ key-wallet/src/wallet/accounts.rs | 78 +++--- key-wallet/src/wallet/helper.rs | 38 ++- 10 files changed, 563 insertions(+), 84 deletions(-) create mode 100644 key-wallet/src/tests/provider_key_derivation_tests.rs diff --git a/key-wallet-ffi/src/account_derivation_tests.rs b/key-wallet-ffi/src/account_derivation_tests.rs index 688560d3a..e0cd2b243 100644 --- a/key-wallet-ffi/src/account_derivation_tests.rs +++ b/key-wallet-ffi/src/account_derivation_tests.rs @@ -246,4 +246,84 @@ mod tests { wallet::wallet_free(wallet); } } + + /// Provider operator (BLS) and platform node (Ed25519) keys derived from a + /// seed through the FFI must match the DashSync/dashbls reference vectors + /// (see rust-dashcore issue #878 and + /// key-wallet/src/tests/provider_key_derivation_tests.rs). + #[test] + #[cfg(all(feature = "bls", feature = "eddsa"))] + fn test_provider_key_derivation_matches_dashsync_reference() { + let mut error = FFIError::default(); + + let mnemonic = std::ffi::CString::new(MNEMONIC).unwrap(); + let passphrase = std::ffi::CString::new("").unwrap(); + + let wallet = unsafe { + wallet::wallet_create_from_mnemonic(mnemonic.as_ptr(), FFINetwork::Mainnet, &mut error) + }; + assert!(!wallet.is_null()); + + let mut seed = [0u8; 64]; + let ok = unsafe { + crate::mnemonic::mnemonic_to_seed( + mnemonic.as_ptr(), + passphrase.as_ptr(), + seed.as_mut_ptr(), + &mut (seed.len()), + &mut error, + ) + }; + assert!(ok); + + let collection = + unsafe { crate::account_collection::wallet_get_account_collection(wallet, &mut error) }; + assert!(!collection.is_null()); + + unsafe { + // BLS operator key 0 at m/9'/5'/3'/3'/0 (legacy HD chain). + let operator_account = + crate::account_collection::account_collection_get_provider_operator_keys(collection) + as *mut crate::account::FFIBLSAccount; + assert!(!operator_account.is_null()); + + let sk0_hex = super::super::bls_account_derive_private_key_from_seed( + operator_account, + seed.as_ptr(), + seed.len(), + 0, + &mut error, + ); + assert!(!sk0_hex.is_null(), "BLS derivation failed: {:?}", error.code); + let sk0 = std::ffi::CStr::from_ptr(sk0_hex).to_str().unwrap(); + assert_eq!(sk0, "11122e1ad656d0610ce0f80d40da874d67ea656a3e66ed371c915ec3a488a43a"); + crate::utils::string_free(sk0_hex); + crate::account::bls_account_free(operator_account); + + // Ed25519 platform node key 0 at m/9'/5'/3'/4'/0' (SLIP-0010). + let platform_account = + crate::account_collection::account_collection_get_provider_platform_keys(collection) + as *mut crate::account::FFIEdDSAAccount; + assert!(!platform_account.is_null()); + + let node_sk0_hex = super::super::eddsa_account_derive_private_key_from_seed( + platform_account, + seed.as_ptr(), + seed.len(), + 0, + &mut error, + ); + assert!(!node_sk0_hex.is_null(), "Ed25519 derivation failed: {:?}", error.code); + let node_sk0 = std::ffi::CStr::from_ptr(node_sk0_hex).to_str().unwrap(); + assert_eq!( + node_sk0, + "5fa238b12be77347abf9b5957bd902d16c6aaca28d25c4267ffacbd7458dceb1" + ); + crate::utils::string_free(node_sk0_hex); + crate::account::eddsa_account_free(platform_account); + + crate::account_collection::account_collection_free(collection); + wallet::wallet_free(wallet); + } + } } diff --git a/key-wallet/examples/account_types.rs b/key-wallet/examples/account_types.rs index 5220624e9..7ecc65b08 100644 --- a/key-wallet/examples/account_types.rs +++ b/key-wallet/examples/account_types.rs @@ -68,7 +68,7 @@ fn main() -> Result<(), Box> { let bls_account = BLSAccount::from_seed( None, AccountType::ProviderVotingKeys, - bls_seed, + &bls_seed, Network::Testnet, )?; @@ -110,7 +110,7 @@ fn main() -> Result<(), Box> { let eddsa_account = EdDSAAccount::from_seed( None, AccountType::IdentityRegistration, - ed25519_seed, + &ed25519_seed, Network::Testnet, )?; @@ -164,7 +164,7 @@ fn main() -> Result<(), Box> { let bls_account = BLSAccount::from_seed( None, AccountType::ProviderVotingKeys, - bls_seed, + &bls_seed, Network::Testnet, )?; let watch_only_bls = bls_account.to_watch_only(); @@ -177,7 +177,7 @@ fn main() -> Result<(), Box> { let eddsa_account = EdDSAAccount::from_seed( None, AccountType::IdentityRegistration, - ed25519_seed, + &ed25519_seed, Network::Testnet, )?; let watch_only_eddsa = eddsa_account.to_watch_only(); diff --git a/key-wallet/src/account/account_collection_test.rs b/key-wallet/src/account/account_collection_test.rs index dead953e0..e6ee1eabc 100644 --- a/key-wallet/src/account/account_collection_test.rs +++ b/key-wallet/src/account/account_collection_test.rs @@ -58,7 +58,7 @@ mod tests { let bls_account = BLSAccount::from_seed( None, AccountType::ProviderOperatorKeys, - [42u8; 32], + &[42u8; 32], Network::Testnet, ) .unwrap(); @@ -70,7 +70,7 @@ mod tests { let eddsa_account = EdDSAAccount::from_seed( None, AccountType::ProviderPlatformKeys, - [99u8; 32], + &[99u8; 32], Network::Testnet, ) .unwrap(); @@ -118,7 +118,7 @@ mod tests { let bls_account = BLSAccount::from_seed( None, AccountType::ProviderVotingKeys, // Wrong! Should be ProviderOperatorKeys - [42u8; 32], + &[42u8; 32], Network::Testnet, ) .unwrap(); @@ -136,7 +136,7 @@ mod tests { let eddsa_account = EdDSAAccount::from_seed( None, AccountType::IdentityRegistration, // Wrong! Should be ProviderPlatformKeys - [99u8; 32], + &[99u8; 32], Network::Testnet, ) .unwrap(); diff --git a/key-wallet/src/account/bls_account.rs b/key-wallet/src/account/bls_account.rs index 59324c0be..33ff1236a 100644 --- a/key-wallet/src/account/bls_account.rs +++ b/key-wallet/src/account/bls_account.rs @@ -109,15 +109,23 @@ impl BLSAccount { }) } - /// Create a BLS account from raw private key bytes (seed) + /// Create a BLS account from a wallet seed (e.g. the 64-byte BIP39 seed). + /// + /// The seed is fed directly into the BLS HD master key (dashbls + /// `ExtendedPrivateKey::FromSeed`) and the account's derivation path + /// (e.g. `m/9'/5'/3'/3'` for mainnet operator keys) is applied in the BLS + /// scheme, matching DashSync. The stored extended public key is the + /// account-level key, so key `i` is its `i`th child. pub fn from_seed( parent_wallet_id: Option>, account_type: AccountType, - seed: [u8; 32], + seed: &[u8], network: Network, ) -> Result { - let bls_private_key = ExtendedBLSPrivKey::new_master(network, &seed)?; - let bls_public_key = ExtendedBLSPubKey::from_private_key(&bls_private_key); + let master = ExtendedBLSPrivKey::new_master(network, seed)?; + let path = account_type.derivation_path(network)?; + let account_xpriv = master.derive_path(&path)?; + let bls_public_key = ExtendedBLSPubKey::from_private_key(&account_xpriv); Ok(Self { parent_wallet_id, @@ -237,7 +245,11 @@ impl } fn has_internal_and_external(&self) -> bool { - true + // Provider operator keys live in a single pool (`account/i`, matching + // DashSync) — there are no separate external/internal chains. This also + // keeps the chain-agnostic seed derivation helpers usable, so operator + // key `i` derived from the seed is `m/9'/coin'/3'/3'/i`. + false } fn has_intermediate_derivation(&self) -> Option { @@ -456,7 +468,7 @@ mod tests { index: 0, standard_account_type: StandardAccountType::BIP44Account, }, - seed, + &seed, Network::Testnet, ) .expect("Failed to create BLS account from seed"); @@ -473,7 +485,7 @@ mod tests { index: 0, standard_account_type: StandardAccountType::BIP44Account, }, - seed, + &seed, Network::Testnet, ) .expect("Failed to create BLS account from seed"); @@ -492,7 +504,7 @@ mod tests { index: 0, standard_account_type: StandardAccountType::BIP44Account, }, - seed, + &seed, Network::Testnet, ) .expect("Failed to create BLS account from seed"); diff --git a/key-wallet/src/account/eddsa_account.rs b/key-wallet/src/account/eddsa_account.rs index 497fd4c37..b9c3a5cd1 100644 --- a/key-wallet/src/account/eddsa_account.rs +++ b/key-wallet/src/account/eddsa_account.rs @@ -84,15 +84,22 @@ impl EdDSAAccount { }) } - /// Create an EdDSA account from a private key (seed) + /// Create an EdDSA account from a wallet seed (e.g. the 64-byte BIP39 seed). + /// + /// The seed is fed directly into the SLIP-0010 Ed25519 master key and the + /// account's derivation path (e.g. `m/9'/5'/3'/4'` for mainnet platform + /// node keys) is applied in the Ed25519 scheme, matching DashSync. The + /// stored extended public key is the account-level key. pub fn from_seed( parent_wallet_id: Option>, account_type: AccountType, - ed25519_seed: [u8; 32], + seed: &[u8], network: Network, ) -> Result { - let ed25519_private_key = ExtendedEd25519PrivKey::new_master(network, &ed25519_seed)?; - let ed25519_public_key = ExtendedEd25519PubKey::from_priv(&ed25519_private_key)?; + let master = ExtendedEd25519PrivKey::new_master(network, seed)?; + let path = account_type.derivation_path(network)?; + let account_xpriv = master.derive_priv(&path)?; + let ed25519_public_key = ExtendedEd25519PubKey::from_priv(&account_xpriv)?; Ok(Self { parent_wallet_id, @@ -454,7 +461,7 @@ mod tests { index: 0, standard_account_type: StandardAccountType::BIP44Account, }, - seed, + &seed, Network::Testnet, ) .expect("Failed to create EdDSA account from seed"); @@ -471,7 +478,7 @@ mod tests { index: 0, standard_account_type: StandardAccountType::BIP44Account, }, - seed, + &seed, Network::Testnet, ) .expect("Failed to create EdDSA account from seed"); @@ -516,7 +523,7 @@ mod tests { index: 0, standard_account_type: StandardAccountType::BIP44Account, }, - seed, + &seed, Network::Testnet, ) .expect("Failed to create EdDSA account from seed"); diff --git a/key-wallet/src/derivation_bls_bip32.rs b/key-wallet/src/derivation_bls_bip32.rs index ec85f0333..8b2460f59 100644 --- a/key-wallet/src/derivation_bls_bip32.rs +++ b/key-wallet/src/derivation_bls_bip32.rs @@ -1,20 +1,26 @@ //! BIP32-like implementation for BLS12-381. //! //! Implementation of hierarchical deterministic wallets for BLS12-381, -//! inspired by BIP32 and adapted for BLS signatures. +//! matching the dashbls (`bls-signatures`) `ExtendedPrivateKey` / +//! `ExtendedPublicKey` scheme used by Dash Core and DashSync for masternode +//! operator keys (DIP-3 `m/9'/coin'/3'/3'`). //! //! Key differences from standard BIP32: //! - Uses BLS12-381 curve instead of secp256k1 //! - Keys are 32 bytes (private) and 48 bytes (public) -//! - Uses "BLS12381 seed" as the HMAC key for master key generation +//! - Uses "BLS HD seed" as the HMAC key for master key generation //! - Supports both hardened and non-hardened derivation +//! - Hardened child HMAC input is `sk(32) || index(4 BE) || {0,1}` — unlike +//! secp256k1 BIP32 there is **no** leading `0x00` byte +//! - Non-hardened derivation feeds the parent public key into the HMAC using +//! the **legacy** G1 serialization (Dash's pre-basic-scheme format), which is +//! what dashbls/DashSync use for the HD chain (`fLegacy = true`) use core::fmt; use dashcore_hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; use std::error; // NOTE: We use Bls12381G2Impl for BLS keys (48-byte public keys) -#[cfg(any(feature = "serde", feature = "bincode"))] use dashcore::blsful::SerializationFormat; use dashcore::blsful::{Bls12381G2Impl, PublicKey as BlsPublicKey, SecretKey as BlsSecretKey}; @@ -170,17 +176,18 @@ impl ExtendedBLSPrivKey { /// Derive a child private key pub fn derive_priv(&self, child: ChildNumber) -> Result { - // Build the input data for HMAC + // Build the input data for HMAC, following dashbls + // `ExtendedPrivateKey::PrivateChild` (extendedprivatekey.cpp) let mut input_data = Vec::new(); if child.is_hardened() { - // Hardened derivation: 0x00 || private_key || index - input_data.push(0x00); + // Hardened derivation: private_key || index + // (no leading 0x00 — that prefix belongs to secp256k1 BIP32, + // where it pads the 33-byte pubkey slot; dashbls doesn't use it) input_data.extend_from_slice(&self.private_key.to_be_bytes()); } else { - // Non-hardened derivation: public_key || index - let public_key_bytes = self.public_key_bytes(); - input_data.extend_from_slice(&public_key_bytes); + // Non-hardened derivation: public_key (legacy serialization) || index + input_data.extend_from_slice(&self.public_key_bytes_legacy()); } let child_bytes = u32::from(child).to_be_bytes(); input_data.extend_from_slice(&child_bytes); @@ -234,7 +241,7 @@ impl ExtendedBLSPrivKey { BlsPublicKey::from(&self.private_key) } - /// Get the public key bytes + /// Get the public key bytes (modern/IETF serialization) pub fn public_key_bytes(&self) -> [u8; 48] { let bytes = self.public_key().to_bytes(); let mut array = [0u8; 48]; @@ -242,6 +249,16 @@ impl ExtendedBLSPrivKey { array } + /// Get the public key bytes in Dash legacy serialization. + /// + /// This is the format dashbls/DashSync use throughout the BLS HD chain. + pub fn public_key_bytes_legacy(&self) -> [u8; 48] { + let bytes = self.public_key().to_bytes_with_mode(SerializationFormat::Legacy); + let mut array = [0u8; 48]; + array.copy_from_slice(&bytes[..48.min(bytes.len())]); + array + } + /// Get the fingerprint of this key pub fn fingerprint(&self) -> Fingerprint { use dashcore_hashes::hash160; @@ -315,9 +332,10 @@ impl ExtendedBLSPubKey { return Err(Error::CannotDeriveFromHardenedPublic); } - // Build the input data for HMAC: public_key || index + // Build the input data for HMAC: public_key (legacy serialization) || index + // — matches dashbls `ExtendedPublicKey::PublicChild` with fLegacy = true. let mut input_data = Vec::new(); - input_data.extend_from_slice(&self.public_key.to_bytes()); + input_data.extend_from_slice(&self.to_bytes_legacy()); let child_bytes = u32::from(child).to_be_bytes(); input_data.extend_from_slice(&child_bytes); @@ -381,7 +399,7 @@ impl ExtendedBLSPubKey { Fingerprint::from_bytes(fingerprint_bytes) } - /// Get the public key bytes + /// Get the public key bytes (modern/IETF serialization) pub fn to_bytes(&self) -> [u8; 48] { let bytes = self.public_key.to_bytes(); let mut array = [0u8; 48]; @@ -389,6 +407,16 @@ impl ExtendedBLSPubKey { array } + /// Get the public key bytes in Dash legacy serialization. + /// + /// This is the format dashbls/DashSync use throughout the BLS HD chain. + pub fn to_bytes_legacy(&self) -> [u8; 48] { + let bytes = self.public_key.to_bytes_with_mode(SerializationFormat::Legacy); + let mut array = [0u8; 48]; + array.copy_from_slice(&bytes[..48.min(bytes.len())]); + array + } + /// Derive at a path (only non-hardened paths allowed) pub fn derive_path(&self, path: &DerivationPath) -> Result { let mut key = self.clone(); @@ -1241,6 +1269,174 @@ mod tests { assert_eq!(child_unhardened.parent_fingerprint, master.fingerprint()); } + /// Reference vectors generated with dashbls (dashpay/bls-signatures @ 0842b17, + /// the C++ library DashSync uses via FFI): `ExtendedPrivateKey::FromSeed` + + /// `PrivateChild(i, fLegacy=true)`. These pin our derivation to the exact + /// bytes Dash Core / DashSync produce (see issue #878). + mod dashbls_vectors { + use super::*; + + /// BIP39 seed for "abandon abandon ... about" (empty passphrase). + const SEED64: &str = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"; + + fn master_from_seed64() -> ExtendedBLSPrivKey { + let seed = hex::decode(SEED64).unwrap(); + ExtendedBLSPrivKey::new_master(Network::Mainnet, &seed).unwrap() + } + + fn hardened(idx: u32) -> ChildNumber { + ChildNumber::from_hardened_idx(idx).unwrap() + } + + #[test] + fn master_from_seed() { + let master = master_from_seed64(); + assert_eq!( + hex::encode(master.private_key.to_be_bytes()), + "27d1e600fe5ce42e9a18fe064aa0c1b8ee6754289013a86eb1e8af985ddc55c5" + ); + assert_eq!( + hex::encode(&master.chain_code[..]), + "2a680de50ab918089c65f47e6f32363eb8fbb915a61e9a10e0f882aa1c12aef9" + ); + assert_eq!( + hex::encode(master.public_key_bytes_legacy()), + "883389cd6c289b97bfa18cc7b7c873397b4d753269d47d2fa29dda1682c1565687ccb19dd016398da7c9724f8a58bdef" + ); + assert_eq!( + hex::encode(master.public_key_bytes()), + "a83389cd6c289b97bfa18cc7b7c873397b4d753269d47d2fa29dda1682c1565687ccb19dd016398da7c9724f8a58bdef" + ); + } + + #[test] + fn mainnet_operator_account_and_keys() { + // DIP-3 operator path m/9'/5'/3'/3' — every level hardened. + let master = master_from_seed64(); + let account = master + .derive_priv(hardened(9)) + .unwrap() + .derive_priv(hardened(5)) + .unwrap() + .derive_priv(hardened(3)) + .unwrap() + .derive_priv(hardened(3)) + .unwrap(); + + assert_eq!( + hex::encode(account.private_key.to_be_bytes()), + "5f36c0e346c6e6275d6550a09857325e3f54f2a962eb09a48f61756f7b4bbfb0" + ); + assert_eq!( + hex::encode(&account.chain_code[..]), + "d9659c1bde2fd0e0f799f2f66bbbcfc7378fdea624d73d5d4749dc7222daea5e" + ); + + // Operator keys 0..2 (non-hardened children — exercises the + // legacy-serialization HMAC input). + let expected = [ + ( + "11122e1ad656d0610ce0f80d40da874d67ea656a3e66ed371c915ec3a488a43a", + "078cad04aae29eb76171937eb7101452b401b026efbc27db840f130374e6a9ec8443d917277f8921e0ba6678a7709875", + "878cad04aae29eb76171937eb7101452b401b026efbc27db840f130374e6a9ec8443d917277f8921e0ba6678a7709875", + ), + ( + "1a4e3318640cd4e50222184d0ea111abf8a0c18a0e5dc3ed45dad85009db4e31", + "0c04974d14df3b5eb23787e21642d25c47609d966bb80f504854e4675657c63f4c4c7056f3f007671b911eb390dec4f7", + "8c04974d14df3b5eb23787e21642d25c47609d966bb80f504854e4675657c63f4c4c7056f3f007671b911eb390dec4f7", + ), + ( + "107ab3ddb1f1277dd082f3f6c9187145a614c6f0c97e4e5f3c8912ba5bba6200", + "0d14cad34409c74f9dd587fc00be01ca0c527e3793f016eb2a612d78ec45c650e8942fbff3e1ab44a21a6e575ebe80a1", + "8d14cad34409c74f9dd587fc00be01ca0c527e3793f016eb2a612d78ec45c650e8942fbff3e1ab44a21a6e575ebe80a1", + ), + ]; + for (i, (sk, pk_legacy, pk_modern)) in expected.iter().enumerate() { + let child = + account.derive_priv(ChildNumber::from_normal_idx(i as u32).unwrap()).unwrap(); + assert_eq!(hex::encode(child.private_key.to_be_bytes()), *sk, "sk {}", i); + assert_eq!( + hex::encode(child.public_key_bytes_legacy()), + *pk_legacy, + "pk_legacy {}", + i + ); + assert_eq!(hex::encode(child.public_key_bytes()), *pk_modern, "pk_modern {}", i); + } + + // Watch-only path: same child 0 via public derivation. + let account_pub = account.to_extended_pub_key(); + let child0_pub = + account_pub.derive_pub(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + assert_eq!(hex::encode(child0_pub.to_bytes_legacy()), expected[0].1); + } + + #[test] + fn testnet_operator_account_and_key0() { + // Testnet operator path m/9'/1'/3'/3'. + let master = master_from_seed64(); + let account = master + .derive_priv(hardened(9)) + .unwrap() + .derive_priv(hardened(1)) + .unwrap() + .derive_priv(hardened(3)) + .unwrap() + .derive_priv(hardened(3)) + .unwrap(); + assert_eq!( + hex::encode(account.private_key.to_be_bytes()), + "05e18aebbe5c73f4dde3dd6a4a204da46c6efa38a38ff4fa5548b1c171154bda" + ); + let child0 = account.derive_priv(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + assert_eq!( + hex::encode(child0.private_key.to_be_bytes()), + "3346dfd71627f9f31cad3ee66fe7b673c32cb077b2eb38c621d7e61c30e46dbd" + ); + assert_eq!( + hex::encode(child0.public_key_bytes_legacy()), + "09d8beabae708de1638487f1aff44b38e8c07d9b09f22d76329d6c8ec01e2ad4d030b660bca40ddbd222373a72c5bcef" + ); + } + + #[test] + fn chia_style_seed8_vectors() { + // dashbls test-suite seed {1, 50, 6, 244, 24, 199, 1, 25}. + let seed = [1u8, 50, 6, 244, 24, 199, 1, 25]; + let master = ExtendedBLSPrivKey::new_master(Network::Testnet, &seed).unwrap(); + assert_eq!( + hex::encode(master.private_key.to_be_bytes()), + "3e9f7b3846c1803703f94c764b51f5ace513b2f02c4d6b2c452d8ce66e5975bd" + ); + assert_eq!( + hex::encode(&master.chain_code[..]), + "d8b12555b4cc5578951e4a7c80031e22019cc0dce168b3ed88115311b8feb1e3" + ); + + // Hardened child 77' + let c77h = master.derive_priv(hardened(77)).unwrap(); + assert_eq!( + hex::encode(c77h.private_key.to_be_bytes()), + "51b31efbd83aeead1e324c5c8248f5a13bb17ba7afe29aeb5ceef7eaff49ed6f" + ); + assert_eq!( + hex::encode(&c77h.chain_code[..]), + "f2c8e4269bb3e54f8179a5c6976d92ca14c3260dd729981e9d15f53049fd698b" + ); + + // Non-hardened child 77 (legacy serialization in HMAC input) + let c77 = master.derive_priv(ChildNumber::from_normal_idx(77).unwrap()).unwrap(); + assert_eq!( + hex::encode(c77.private_key.to_be_bytes()), + "3ef4f8b4d262fb8981665532b531c7889798044f7cbe4d5fae5e30435f746044" + ); + assert_eq!( + hex::encode(&c77.chain_code[..]), + "f428f5f011f52569c0b2004aaeda0744259f4247fd5e77649d3d25e6b491cc53" + ); + } + } + #[test] fn test_zeroize_clears_key_material() { use zeroize::Zeroize; diff --git a/key-wallet/src/tests/mod.rs b/key-wallet/src/tests/mod.rs index 8db87a128..acb7a6378 100644 --- a/key-wallet/src/tests/mod.rs +++ b/key-wallet/src/tests/mod.rs @@ -22,6 +22,8 @@ mod managed_account_collection_tests; mod performance_tests; +mod provider_key_derivation_tests; + mod special_transaction_matching_tests; mod special_transaction_tests; diff --git a/key-wallet/src/tests/provider_key_derivation_tests.rs b/key-wallet/src/tests/provider_key_derivation_tests.rs new file mode 100644 index 000000000..3e8d52e96 --- /dev/null +++ b/key-wallet/src/tests/provider_key_derivation_tests.rs @@ -0,0 +1,162 @@ +//! End-to-end tests for provider (masternode) key derivation. +//! +//! These pin the wallet-level BLS operator key and Ed25519 platform node key +//! flows to reference vectors produced by the implementations DashSync uses +//! (dashbls `ExtendedPrivateKey` with `fLegacy = true` for BLS, SLIP-0010 for +//! Ed25519), so the keys match dashwallet-ios / DashSync for the same +//! mnemonic. See . + +use crate::account::derivation::AccountDerivation; +use crate::account::AccountType; +use crate::mnemonic::{Language, Mnemonic}; +use crate::wallet::initialization::WalletAccountCreationOptions; +use crate::wallet::Wallet; +use crate::{ChildNumber, Network}; + +const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +/// BIP39 seed for [`TEST_MNEMONIC`] with an empty passphrase. +const TEST_SEED_HEX: &str = + "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"; + +fn test_wallet(network: Network) -> Wallet { + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).unwrap(); + Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default).unwrap() +} + +#[cfg(feature = "bls")] +#[test] +fn bls_operator_keys_match_dashbls_reference() { + // Reference vectors generated with dashbls (dashpay/bls-signatures @ + // 0842b17): ExtendedPrivateKey::FromSeed(seed) then PrivateChild(i, + // fLegacy=true) along m/9'/5'/3'/3', then child i (non-hardened). + let wallet = test_wallet(Network::Mainnet); + let account = wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("operator account should be auto-created for mnemonic wallets"); + + // The stored account xpub must be the account-level key at m/9'/5'/3'/3'. + assert_eq!( + hex::encode(account.bls_public_key.to_bytes_legacy()), + "8d794d053504db3727c1f51aea2112e440fadbade687a9c0243b61523c8ab8eb64061f0a5ec5d8df4b7ec8bdfe722c19" + ); + + // Operator key 0 via watch-side (non-hardened public) derivation. + let key0_pub = + account.bls_public_key.derive_pub(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + assert_eq!( + hex::encode(key0_pub.to_bytes_legacy()), + "078cad04aae29eb76171937eb7101452b401b026efbc27db840f130374e6a9ec8443d917277f8921e0ba6678a7709875" + ); + // Same point in modern/IETF (basic-scheme) serialization, as it appears in + // v19+ ProRegTx payloads. + assert_eq!( + hex::encode(key0_pub.to_bytes()), + "878cad04aae29eb76171937eb7101452b401b026efbc27db840f130374e6a9ec8443d917277f8921e0ba6678a7709875" + ); + + // Operator secret key 0 via the seed-based signing path. + let seed = hex::decode(TEST_SEED_HEX).unwrap(); + let sk0 = account.derive_from_seed_private_key_at(&seed, 0).unwrap(); + assert_eq!( + hex::encode(sk0.to_be_bytes()), + "11122e1ad656d0610ce0f80d40da874d67ea656a3e66ed371c915ec3a488a43a" + ); + let sk1 = account.derive_from_seed_private_key_at(&seed, 1).unwrap(); + assert_eq!( + hex::encode(sk1.to_be_bytes()), + "1a4e3318640cd4e50222184d0ea111abf8a0c18a0e5dc3ed45dad85009db4e31" + ); +} + +#[cfg(feature = "bls")] +#[test] +fn bls_operator_keys_testnet_match_dashbls_reference() { + // Testnet uses coin type 1: m/9'/1'/3'/3'. + let wallet = test_wallet(Network::Testnet); + let account = wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("operator account should be auto-created for mnemonic wallets"); + + let key0_pub = + account.bls_public_key.derive_pub(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + assert_eq!( + hex::encode(key0_pub.to_bytes_legacy()), + "09d8beabae708de1638487f1aff44b38e8c07d9b09f22d76329d6c8ec01e2ad4d030b660bca40ddbd222373a72c5bcef" + ); + + let seed = hex::decode(TEST_SEED_HEX).unwrap(); + let sk0 = account.derive_from_seed_private_key_at(&seed, 0).unwrap(); + assert_eq!( + hex::encode(sk0.to_be_bytes()), + "3346dfd71627f9f31cad3ee66fe7b673c32cb077b2eb38c621d7e61c30e46dbd" + ); +} + +#[cfg(feature = "eddsa")] +#[test] +fn ed25519_platform_node_keys_match_slip10_reference() { + // Reference vectors computed with an independent SLIP-0010 implementation: + // master from the BIP39 seed, then hardened path m/9'/5'/3'/4' and child 0'. + let wallet = test_wallet(Network::Mainnet); + let account = wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("platform node account should be auto-created for mnemonic wallets"); + + // The stored account key must be the account-level key at m/9'/5'/3'/4'. + let expected_account_sk = + hex::decode("80035d9c2f89971a9c9fad826bba8be9328f1686ae555e912949c2c32800c379").unwrap(); + let expected_account_pk = dashcore::ed25519_dalek::SigningKey::from_bytes( + expected_account_sk.as_slice().try_into().unwrap(), + ) + .verifying_key(); + assert_eq!(account.ed25519_public_key.public_key, expected_account_pk); + + // Platform node key 0 (hardened child 0') via the seed-based signing path. + let seed = hex::decode(TEST_SEED_HEX).unwrap(); + let sk0 = account.derive_from_seed_private_key_at(&seed, 0).unwrap(); + assert_eq!( + hex::encode(sk0.to_bytes()), + "5fa238b12be77347abf9b5957bd902d16c6aaca28d25c4267ffacbd7458dceb1" + ); +} + +/// Wallets created from a bare extended private key carry no seed, so +/// DashSync-compatible BLS/Ed25519 provider keys cannot be derived for them. +/// Default account creation must skip those accounts rather than fabricate +/// keys that never correspond to anything on-chain, and explicit account +/// creation without a seed must fail. +#[cfg(all(feature = "bls", feature = "eddsa"))] +#[test] +fn seedless_wallet_skips_provider_operator_and_platform_accounts() { + use crate::bip32::ExtendedPrivKey; + use crate::Error; + + let seed = hex::decode(TEST_SEED_HEX).unwrap(); + let master = ExtendedPrivKey::new_master(Network::Testnet, &seed).unwrap(); + let mut wallet = + Wallet::from_extended_key(master, WalletAccountCreationOptions::Default).unwrap(); + + assert!(wallet.accounts.bls_account_of_type(AccountType::ProviderOperatorKeys).is_none()); + assert!(wallet.accounts.eddsa_account_of_type(AccountType::ProviderPlatformKeys).is_none()); + + // Explicit creation without a seed fails with a typed error... + assert!(matches!( + wallet.add_bls_account(AccountType::ProviderOperatorKeys, None), + Err(Error::KeylessWalletRequiresAccountKey { .. }) + )); + assert!(matches!( + wallet.add_eddsa_account(AccountType::ProviderPlatformKeys, None), + Err(Error::KeylessWalletRequiresAccountKey { .. }) + )); + + // ...but works when the seed is supplied explicitly. + wallet.add_bls_account(AccountType::ProviderOperatorKeys, Some(&seed)).unwrap(); + wallet.add_eddsa_account(AccountType::ProviderPlatformKeys, Some(&seed)).unwrap(); + assert!(wallet.accounts.bls_account_of_type(AccountType::ProviderOperatorKeys).is_some()); + assert!(wallet.accounts.eddsa_account_of_type(AccountType::ProviderPlatformKeys).is_some()); +} diff --git a/key-wallet/src/wallet/accounts.rs b/key-wallet/src/wallet/accounts.rs index 776065c54..6982252f3 100644 --- a/key-wallet/src/wallet/accounts.rs +++ b/key-wallet/src/wallet/accounts.rs @@ -73,22 +73,27 @@ impl Wallet { /// /// BLS accounts are used for Platform/masternode operations. /// + /// The seed (whether provided or taken from the wallet) is fed directly + /// into the BLS HD master key and the account's derivation path is applied + /// in the BLS scheme, matching DashSync/dashbls. + /// /// # Arguments /// * `account_type` - The type of account (must be ProviderOperatorKeys) - /// * `bls_seed` - Optional 32-byte seed for BLS key generation. If not provided, - /// the account is derived from the wallet's in-wallet private key. + /// * `bls_seed` - Optional wallet seed (typically the 64-byte BIP39 seed) + /// for BLS key generation. If not provided, the wallet's own seed is used. /// /// # Returns /// Ok(()) if the account was successfully added /// /// # Errors /// Returns [`Error::KeylessWalletRequiresAccountKey`] when `bls_seed` is `None` - /// on a keyless wallet (watch-only / external-signable): pass the seed via `Some(..)`. + /// on a wallet without a stored seed (watch-only / external-signable / + /// created from an extended private key): pass the seed via `Some(..)`. #[cfg(feature = "bls")] pub fn add_bls_account( &mut self, account_type: AccountType, - bls_seed: Option<[u8; 32]>, + bls_seed: Option<&[u8]>, ) -> Result<()> { // Validate account type if !matches!(account_type, AccountType::ProviderOperatorKeys) { @@ -100,28 +105,18 @@ impl Wallet { // Get a unique wallet ID for this wallet first let wallet_id = self.get_wallet_id(); - // Create the BLS account based on whether we have a seed or need to derive + // Use the provided seed, or fall back to the wallet's own seed. + // BLS provider keys must be derived from the raw seed in the BLS + // scheme (dashbls ExtendedPrivateKey::FromSeed) — they cannot be + // derived from a secp256k1 extended private key. let bls_account = if let Some(seed) = bls_seed { - // Use the provided seed BLSAccount::from_seed(Some(wallet_id.to_vec()), account_type, seed, self.network)? } else { - // Derive from wallet's private key - let derivation_path = account_type.derivation_path(self.network)?; - - let root_key = self.root_extended_priv_key().map_err(|_| { - Error::KeylessWalletRequiresAccountKey { - account_type, - required_key: "32-byte BLS seed", - } + let seed = self.wallet_seed_bytes().ok_or(Error::KeylessWalletRequiresAccountKey { + account_type, + required_key: "wallet seed (e.g. 64-byte BIP39 seed)", })?; - let master_key = root_key.to_extended_priv_key(self.network); - let secp = Secp256k1::new(); - let account_xpriv = - master_key.derive_priv(&secp, &derivation_path).map_err(Error::Bip32)?; - - // Create BLS seed from derived private key - let seed = account_xpriv.private_key.secret_bytes(); - BLSAccount::from_seed(Some(wallet_id.to_vec()), account_type, seed, self.network)? + BLSAccount::from_seed(Some(wallet_id.to_vec()), account_type, &seed, self.network)? }; // Check if account already exists @@ -142,22 +137,28 @@ impl Wallet { /// /// EdDSA accounts are used for Platform operations. /// + /// The seed (whether provided or taken from the wallet) is fed directly + /// into the SLIP-0010 Ed25519 master key and the account's derivation path + /// is applied in the Ed25519 scheme, matching DashSync. + /// /// # Arguments /// * `account_type` - The type of account (must be ProviderPlatformKeys) - /// * `ed25519_seed` - Optional 32-byte seed for Ed25519 key generation. If not provided, - /// the account is derived from the wallet's in-wallet private key. + /// * `ed25519_seed` - Optional wallet seed (typically the 64-byte BIP39 + /// seed) for Ed25519 key generation. If not provided, the wallet's own + /// seed is used. /// /// # Returns /// Ok(()) if the account was successfully added /// /// # Errors /// Returns [`Error::KeylessWalletRequiresAccountKey`] when `ed25519_seed` is `None` - /// on a keyless wallet (watch-only / external-signable): pass the seed via `Some(..)`. + /// on a wallet without a stored seed (watch-only / external-signable / + /// created from an extended private key): pass the seed via `Some(..)`. #[cfg(feature = "eddsa")] pub fn add_eddsa_account( &mut self, account_type: AccountType, - ed25519_seed: Option<[u8; 32]>, + ed25519_seed: Option<&[u8]>, ) -> Result<()> { // Validate account type if !matches!(account_type, AccountType::ProviderPlatformKeys) { @@ -169,28 +170,17 @@ impl Wallet { // Get a unique wallet ID for this wallet first let wallet_id = self.get_wallet_id(); - // Create the EdDSA account based on whether we have a seed or need to derive + // Use the provided seed, or fall back to the wallet's own seed. + // Platform node keys must be derived from the raw seed via SLIP-0010 — + // they cannot be derived from a secp256k1 extended private key. let eddsa_account = if let Some(seed) = ed25519_seed { - // Use the provided seed EdDSAAccount::from_seed(Some(wallet_id.to_vec()), account_type, seed, self.network)? } else { - // Derive from wallet's private key - let derivation_path = account_type.derivation_path(self.network)?; - - let root_key = self.root_extended_priv_key().map_err(|_| { - Error::KeylessWalletRequiresAccountKey { - account_type, - required_key: "32-byte Ed25519 seed", - } + let seed = self.wallet_seed_bytes().ok_or(Error::KeylessWalletRequiresAccountKey { + account_type, + required_key: "wallet seed (e.g. 64-byte BIP39 seed)", })?; - let master_key = root_key.to_extended_priv_key(self.network); - let secp = Secp256k1::new(); - let account_xpriv = - master_key.derive_priv(&secp, &derivation_path).map_err(Error::Bip32)?; - - // Create Ed25519 seed from derived private key - let seed = account_xpriv.private_key.secret_bytes(); - EdDSAAccount::from_seed(Some(wallet_id.to_vec()), account_type, seed, self.network)? + EdDSAAccount::from_seed(Some(wallet_id.to_vec()), account_type, &seed, self.network)? }; // Check if account already exists diff --git a/key-wallet/src/wallet/helper.rs b/key-wallet/src/wallet/helper.rs index c5a519ed4..a282804e8 100644 --- a/key-wallet/src/wallet/helper.rs +++ b/key-wallet/src/wallet/helper.rs @@ -101,6 +101,28 @@ impl Wallet { matches!(self.wallet_type, WalletType::Seed { .. } | WalletType::Mnemonic { .. }) } + /// Get the wallet's 64-byte seed, if it has one. + /// + /// For mnemonic wallets this is the BIP39 seed (empty passphrase — the + /// same convention wallet construction uses to derive the root key). + /// Wallets created from an extended private key, watch-only wallets and + /// external-signable wallets have no seed and return `None`. + pub fn wallet_seed_bytes(&self) -> Option<[u8; 64]> { + match &self.wallet_type { + WalletType::Mnemonic { + mnemonic, + .. + } => Some(mnemonic.to_seed("")), + WalletType::Seed { + seed, + .. + } => Some(*seed.as_bytes()), + WalletType::ExtendedPrivKey(_) + | WalletType::ExternalSignable + | WalletType::WatchOnly => None, + } + } + /// Create accounts based on the provided creation options pub(crate) fn create_accounts_from_options( &mut self, @@ -321,10 +343,18 @@ impl Wallet { // Provider keys accounts self.add_account(AccountType::ProviderVotingKeys, None)?; self.add_account(AccountType::ProviderOwnerKeys, None)?; - #[cfg(feature = "bls")] - self.add_bls_account(AccountType::ProviderOperatorKeys, None)?; - #[cfg(feature = "eddsa")] - self.add_eddsa_account(AccountType::ProviderPlatformKeys, None)?; + // Operator (BLS) and platform-node (Ed25519) keys are derived from the + // raw wallet seed in their own scheme (matching DashSync/dashbls), not + // from the secp256k1 root key — so they can only be auto-created for + // wallets that carry a seed. Seedless wallets (extended-priv-key based) + // can still add them explicitly via add_bls_account/add_eddsa_account + // with an externally supplied seed. + if self.has_seed() { + #[cfg(feature = "bls")] + self.add_bls_account(AccountType::ProviderOperatorKeys, None)?; + #[cfg(feature = "eddsa")] + self.add_eddsa_account(AccountType::ProviderPlatformKeys, None)?; + } Ok(()) } From 4b697654e6414be1fbbbc16127c401edcd3609c9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 04:49:39 +0700 Subject: [PATCH 2/2] refactor(key-wallet): parameterize BLS HD serialization mode (modern default, legacy explicit) Mirror dashbls's fLegacy parameter instead of hard-coding legacy in the HD chain: derive_priv/derive_pub/derive_path use the modern (IETF) serialization like dashbls PrivateChild/PublicChild with the default fLegacy=false, and *_legacy / *_with_mode variants select the legacy Dash serialization. The provider-key account layer (BLSAccount, BLS address pools) derives with the legacy variants, which is what dashbls/DashSync use for masternode operator keys. Hardened derivation never serializes the public key, so both modes agree there; only non-hardened children differ. Modern-mode derivation is pinned by dashbls-generated vectors alongside the legacy ones. Co-Authored-By: Claude Fable 5 --- key-wallet/src/account/bls_account.rs | 17 +- key-wallet/src/derivation_bls_bip32.rs | 205 ++++++++++++++++-- .../src/managed_account/address_pool.rs | 6 +- .../tests/provider_key_derivation_tests.rs | 4 +- 4 files changed, 200 insertions(+), 32 deletions(-) diff --git a/key-wallet/src/account/bls_account.rs b/key-wallet/src/account/bls_account.rs index 33ff1236a..5f8601eb5 100644 --- a/key-wallet/src/account/bls_account.rs +++ b/key-wallet/src/account/bls_account.rs @@ -124,7 +124,7 @@ impl BLSAccount { ) -> Result { let master = ExtendedBLSPrivKey::new_master(network, seed)?; let path = account_type.derivation_path(network)?; - let account_xpriv = master.derive_path(&path)?; + let account_xpriv = master.derive_path_legacy(&path)?; let bls_public_key = ExtendedBLSPubKey::from_private_key(&account_xpriv); Ok(Self { @@ -147,7 +147,7 @@ impl BLSAccount { return Err(Error::WatchOnly); } let child_num = ChildNumber::from_normal_idx(index)?; - current_key = current_key.ckd_pub(child_num)?; + current_key = current_key.derive_pub_legacy(child_num)?; } Ok(current_key) @@ -278,9 +278,10 @@ impl // Get the derivation path for this account type let path = self.account_type.derivation_path(self.network)?; - // Derive the account private key from master + // Derive the account private key from master (legacy mode, matching + // dashbls/DashSync for provider operator keys) master_xpriv - .derive_path(&path) + .derive_path_legacy(&path) .map_err(|e| Error::InvalidParameter(format!("BLS derivation error: {}", e))) } @@ -296,9 +297,9 @@ impl return Err(Error::WatchOnly); } - // Derive the child private key from account private key + // Derive the child private key from account private key (legacy mode) account_xpriv - .derive_path(child_path) + .derive_path_legacy(child_path) .map_err(|e| Error::InvalidParameter(format!("BLS child derivation error: {}", e))) } @@ -315,9 +316,9 @@ impl } } - // Derive the child public key from account public key + // Derive the child public key from account public key (legacy mode) self.bls_public_key - .derive_path(child_path) + .derive_path_legacy(child_path) .map_err(|e| Error::InvalidParameter(format!("BLS public key derivation error: {}", e))) } diff --git a/key-wallet/src/derivation_bls_bip32.rs b/key-wallet/src/derivation_bls_bip32.rs index 8b2460f59..44e6fc5f7 100644 --- a/key-wallet/src/derivation_bls_bip32.rs +++ b/key-wallet/src/derivation_bls_bip32.rs @@ -12,9 +12,27 @@ //! - Supports both hardened and non-hardened derivation //! - Hardened child HMAC input is `sk(32) || index(4 BE) || {0,1}` — unlike //! secp256k1 BIP32 there is **no** leading `0x00` byte -//! - Non-hardened derivation feeds the parent public key into the HMAC using -//! the **legacy** G1 serialization (Dash's pre-basic-scheme format), which is -//! what dashbls/DashSync use for the HD chain (`fLegacy = true`) +//! +//! # Serialization modes +//! +//! Non-hardened derivation feeds the parent public key into the HMAC, so the +//! G1 serialization format is part of the derivation itself — dashbls +//! parameterizes it as `fLegacy`. Both modes are supported, mirroring dashbls: +//! +//! - [`ExtendedBLSPrivKey::derive_priv`] / [`ExtendedBLSPubKey::derive_pub`] +//! use the **modern** (IETF/basic-scheme) serialization, like dashbls +//! `PrivateChild(i)` with the default `fLegacy = false`. +//! - [`ExtendedBLSPrivKey::derive_priv_legacy`] / +//! [`ExtendedBLSPubKey::derive_pub_legacy`] use the **legacy** Dash +//! serialization (`fLegacy = true`). This is what dashbls/DashSync use for +//! masternode operator keys (DIP-3 `m/9'/coin'/3'/3'`), so the provider-key +//! account layer derives with these. +//! - The `*_with_mode` variants take an explicit [`SerializationFormat`]. +//! +//! Hardened derivation never serializes the public key, so the mode only +//! matters for non-hardened children. Output serialization is likewise +//! available in both formats ([`ExtendedBLSPubKey::to_bytes`] / +//! [`ExtendedBLSPubKey::to_bytes_legacy`]). use core::fmt; use dashcore_hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; @@ -174,8 +192,35 @@ impl ExtendedBLSPrivKey { }) } - /// Derive a child private key + /// Derive a child private key using the modern (IETF) public key + /// serialization for non-hardened children. + /// + /// Equivalent to dashbls `PrivateChild(i)` with the default + /// `fLegacy = false`. For Dash masternode operator keys use + /// [`Self::derive_priv_legacy`], which matches DashSync. pub fn derive_priv(&self, child: ChildNumber) -> Result { + self.derive_priv_with_mode(child, SerializationFormat::Modern) + } + + /// Derive a child private key using the legacy Dash public key + /// serialization for non-hardened children. + /// + /// Equivalent to dashbls `PrivateChild(i, fLegacy = true)` — the mode + /// dashbls/DashSync use for masternode operator keys. + pub fn derive_priv_legacy(&self, child: ChildNumber) -> Result { + self.derive_priv_with_mode(child, SerializationFormat::Legacy) + } + + /// Derive a child private key with an explicit serialization mode. + /// + /// The mode selects the G1 serialization of the parent public key in the + /// HMAC input for non-hardened children; hardened derivation never + /// serializes the public key, so both modes agree there. + pub fn derive_priv_with_mode( + &self, + child: ChildNumber, + format: SerializationFormat, + ) -> Result { // Build the input data for HMAC, following dashbls // `ExtendedPrivateKey::PrivateChild` (extendedprivatekey.cpp) let mut input_data = Vec::new(); @@ -186,8 +231,8 @@ impl ExtendedBLSPrivKey { // where it pads the 33-byte pubkey slot; dashbls doesn't use it) input_data.extend_from_slice(&self.private_key.to_be_bytes()); } else { - // Non-hardened derivation: public_key (legacy serialization) || index - input_data.extend_from_slice(&self.public_key_bytes_legacy()); + // Non-hardened derivation: public_key || index + input_data.extend_from_slice(&self.public_key().to_bytes_with_mode(format)); } let child_bytes = u32::from(child).to_be_bytes(); input_data.extend_from_slice(&child_bytes); @@ -281,11 +326,27 @@ impl ExtendedBLSPrivKey { } } - /// Derive at a path + /// Derive at a path using the modern (IETF) serialization mode + /// (see [`Self::derive_priv`]). pub fn derive_path(&self, path: &DerivationPath) -> Result { + self.derive_path_with_mode(path, SerializationFormat::Modern) + } + + /// Derive at a path using the legacy Dash serialization mode + /// (see [`Self::derive_priv_legacy`]). + pub fn derive_path_legacy(&self, path: &DerivationPath) -> Result { + self.derive_path_with_mode(path, SerializationFormat::Legacy) + } + + /// Derive at a path with an explicit serialization mode. + pub fn derive_path_with_mode( + &self, + path: &DerivationPath, + format: SerializationFormat, + ) -> Result { let mut key = self.clone(); for child in path.as_ref() { - key = key.derive_priv(*child)?; + key = key.derive_priv_with_mode(*child, format)?; } Ok(key) } @@ -321,21 +382,47 @@ impl ExtendedBLSPubKey { } } - /// Derive a child public key (only for non-hardened derivation) + /// Derive a child public key using the modern (IETF) serialization mode + /// (only for non-hardened derivation). pub fn ckd_pub(&self, child: ChildNumber) -> Result { self.derive_pub(child) } - /// Derive a child public key (only for non-hardened derivation) + /// Derive a child public key using the modern (IETF) public key + /// serialization (only for non-hardened derivation). + /// + /// Equivalent to dashbls `PublicChild(i)` with the default + /// `fLegacy = false`. For Dash masternode operator keys use + /// [`Self::derive_pub_legacy`], which matches DashSync. pub fn derive_pub(&self, child: ChildNumber) -> Result { + self.derive_pub_with_mode(child, SerializationFormat::Modern) + } + + /// Derive a child public key using the legacy Dash public key + /// serialization (only for non-hardened derivation). + /// + /// Equivalent to dashbls `PublicChild(i, fLegacy = true)` — the mode + /// dashbls/DashSync use for masternode operator keys. + pub fn derive_pub_legacy(&self, child: ChildNumber) -> Result { + self.derive_pub_with_mode(child, SerializationFormat::Legacy) + } + + /// Derive a child public key with an explicit serialization mode + /// (only for non-hardened derivation). + pub fn derive_pub_with_mode( + &self, + child: ChildNumber, + format: SerializationFormat, + ) -> Result { if child.is_hardened() { return Err(Error::CannotDeriveFromHardenedPublic); } - // Build the input data for HMAC: public_key (legacy serialization) || index - // — matches dashbls `ExtendedPublicKey::PublicChild` with fLegacy = true. + // Build the input data for HMAC: public_key || index — matches + // dashbls `ExtendedPublicKey::PublicChild`, whose fLegacy flag + // corresponds to `format`. let mut input_data = Vec::new(); - input_data.extend_from_slice(&self.to_bytes_legacy()); + input_data.extend_from_slice(&self.public_key.to_bytes_with_mode(format)); let child_bytes = u32::from(child).to_be_bytes(); input_data.extend_from_slice(&child_bytes); @@ -417,11 +504,28 @@ impl ExtendedBLSPubKey { array } - /// Derive at a path (only non-hardened paths allowed) + /// Derive at a path using the modern (IETF) serialization mode + /// (only non-hardened paths allowed; see [`Self::derive_pub`]). pub fn derive_path(&self, path: &DerivationPath) -> Result { + self.derive_path_with_mode(path, SerializationFormat::Modern) + } + + /// Derive at a path using the legacy Dash serialization mode + /// (only non-hardened paths allowed; see [`Self::derive_pub_legacy`]). + pub fn derive_path_legacy(&self, path: &DerivationPath) -> Result { + self.derive_path_with_mode(path, SerializationFormat::Legacy) + } + + /// Derive at a path with an explicit serialization mode + /// (only non-hardened paths allowed). + pub fn derive_path_with_mode( + &self, + path: &DerivationPath, + format: SerializationFormat, + ) -> Result { let mut key = self.clone(); for child in path.as_ref() { - key = key.derive_pub(*child)?; + key = key.derive_pub_with_mode(*child, format)?; } Ok(key) } @@ -1352,8 +1456,9 @@ mod tests { ), ]; for (i, (sk, pk_legacy, pk_modern)) in expected.iter().enumerate() { - let child = - account.derive_priv(ChildNumber::from_normal_idx(i as u32).unwrap()).unwrap(); + let child = account + .derive_priv_legacy(ChildNumber::from_normal_idx(i as u32).unwrap()) + .unwrap(); assert_eq!(hex::encode(child.private_key.to_be_bytes()), *sk, "sk {}", i); assert_eq!( hex::encode(child.public_key_bytes_legacy()), @@ -1367,7 +1472,7 @@ mod tests { // Watch-only path: same child 0 via public derivation. let account_pub = account.to_extended_pub_key(); let child0_pub = - account_pub.derive_pub(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + account_pub.derive_pub_legacy(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); assert_eq!(hex::encode(child0_pub.to_bytes_legacy()), expected[0].1); } @@ -1388,7 +1493,8 @@ mod tests { hex::encode(account.private_key.to_be_bytes()), "05e18aebbe5c73f4dde3dd6a4a204da46c6efa38a38ff4fa5548b1c171154bda" ); - let child0 = account.derive_priv(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + let child0 = + account.derive_priv_legacy(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); assert_eq!( hex::encode(child0.private_key.to_be_bytes()), "3346dfd71627f9f31cad3ee66fe7b673c32cb077b2eb38c621d7e61c30e46dbd" @@ -1425,7 +1531,7 @@ mod tests { ); // Non-hardened child 77 (legacy serialization in HMAC input) - let c77 = master.derive_priv(ChildNumber::from_normal_idx(77).unwrap()).unwrap(); + let c77 = master.derive_priv_legacy(ChildNumber::from_normal_idx(77).unwrap()).unwrap(); assert_eq!( hex::encode(c77.private_key.to_be_bytes()), "3ef4f8b4d262fb8981665532b531c7889798044f7cbe4d5fae5e30435f746044" @@ -1435,6 +1541,65 @@ mod tests { "f428f5f011f52569c0b2004aaeda0744259f4247fd5e77649d3d25e6b491cc53" ); } + + #[test] + fn modern_mode_vectors() { + // dashbls `PrivateChild(i)` with the default fLegacy = false — + // the mode used by `derive_priv`/`derive_pub` without suffix. + let seed = [1u8, 50, 6, 244, 24, 199, 1, 25]; + let master = ExtendedBLSPrivKey::new_master(Network::Testnet, &seed).unwrap(); + let c77 = master.derive_priv(ChildNumber::from_normal_idx(77).unwrap()).unwrap(); + assert_eq!( + hex::encode(c77.private_key.to_be_bytes()), + "0f9b101b475e449c9995032e138b432a330738b6401f675f0632385fe8d349bf" + ); + assert_eq!( + hex::encode(&c77.chain_code[..]), + "c7b09e00d6b9b1676e8714e1060e0324787734809ae557a4bc8c07e9b1304ed0" + ); + assert_eq!( + hex::encode(c77.public_key_bytes()), + "a63fa533db03b400030a5eb163433ac7c8700d2301c4242e03db58d516dea0d52768d1b0d29e9f28f7707ce96d2d6108" + ); + + // Modern-mode child 0 under the operator path from the BIP39 seed. + // Hardened levels agree across modes; the non-hardened leaf differs. + let master64 = master_from_seed64(); + let account = master64 + .derive_priv(hardened(9)) + .unwrap() + .derive_priv(hardened(5)) + .unwrap() + .derive_priv(hardened(3)) + .unwrap() + .derive_priv(hardened(3)) + .unwrap(); + let child0_modern = + account.derive_priv(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + assert_eq!( + hex::encode(child0_modern.private_key.to_be_bytes()), + "1669d6cc8ac08fa377d63dafcf83f1fa6aee09e2df58c490b1b1a0b0999417ec" + ); + assert_eq!( + hex::encode(child0_modern.public_key_bytes()), + "8f5d504fee1026394728781f004fee70480335c1f53156124b23e45386c7c1e2973efee3eab4ae60650fdaa8ae4460d0" + ); + + // Same leaf via legacy mode is a different key entirely. + let child0_legacy = + account.derive_priv_legacy(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + assert_ne!( + child0_modern.private_key.to_be_bytes(), + child0_legacy.private_key.to_be_bytes() + ); + + // Private/public derivation stays consistent in modern mode too. + let child0_pub = account + .to_extended_pub_key() + .derive_pub(ChildNumber::from_normal_idx(0).unwrap()) + .unwrap(); + assert_eq!(child0_pub.to_bytes(), child0_modern.public_key_bytes()); + } } #[test] diff --git a/key-wallet/src/managed_account/address_pool.rs b/key-wallet/src/managed_account/address_pool.rs index 045098aa1..020c3f8f2 100644 --- a/key-wallet/src/managed_account/address_pool.rs +++ b/key-wallet/src/managed_account/address_pool.rs @@ -143,9 +143,11 @@ impl KeySource { #[cfg(feature = "bls")] KeySource::BLSPrivate(xprv) => { // BLS HD derivation using the proper BIP32-like derivation + // Legacy mode: BLS pools exist only for provider operator keys, + // which dashbls/DashSync derive with fLegacy = true. let mut derived = xprv.clone(); for child_num in path.as_ref() { - derived = derived.derive_priv(*child_num).map_err(|e| { + derived = derived.derive_priv_legacy(*child_num).map_err(|e| { Error::InvalidParameter(format!("BLS derivation error: {:?}", e)) })?; } @@ -161,7 +163,7 @@ impl KeySource { "Cannot derive hardened child from BLS public key".into(), )); } - derived = derived.derive_pub(*child_num).map_err(|e| { + derived = derived.derive_pub_legacy(*child_num).map_err(|e| { Error::InvalidParameter(format!("BLS public derivation error: {:?}", e)) })?; } diff --git a/key-wallet/src/tests/provider_key_derivation_tests.rs b/key-wallet/src/tests/provider_key_derivation_tests.rs index 3e8d52e96..a9bd41d03 100644 --- a/key-wallet/src/tests/provider_key_derivation_tests.rs +++ b/key-wallet/src/tests/provider_key_derivation_tests.rs @@ -45,7 +45,7 @@ fn bls_operator_keys_match_dashbls_reference() { // Operator key 0 via watch-side (non-hardened public) derivation. let key0_pub = - account.bls_public_key.derive_pub(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + account.bls_public_key.derive_pub_legacy(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); assert_eq!( hex::encode(key0_pub.to_bytes_legacy()), "078cad04aae29eb76171937eb7101452b401b026efbc27db840f130374e6a9ec8443d917277f8921e0ba6678a7709875" @@ -82,7 +82,7 @@ fn bls_operator_keys_testnet_match_dashbls_reference() { .expect("operator account should be auto-created for mnemonic wallets"); let key0_pub = - account.bls_public_key.derive_pub(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); + account.bls_public_key.derive_pub_legacy(ChildNumber::from_normal_idx(0).unwrap()).unwrap(); assert_eq!( hex::encode(key0_pub.to_bytes_legacy()), "09d8beabae708de1638487f1aff44b38e8c07d9b09f22d76329d6c8ec01e2ad4d030b660bca40ddbd222373a72c5bcef"