diff --git a/Cargo.lock b/Cargo.lock index 5148cee60c..6e400baaa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1636,8 +1636,8 @@ dependencies = [ [[package]] name = "dash-network" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "bincode", "bincode_derive", @@ -1647,8 +1647,8 @@ dependencies = [ [[package]] name = "dash-network-seeds" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "dash-network", ] @@ -1724,8 +1724,8 @@ dependencies = [ [[package]] name = "dash-spv" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "async-trait", "chrono", @@ -1753,8 +1753,8 @@ dependencies = [ [[package]] name = "dashcore" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "anyhow", "base64-compat", @@ -1779,13 +1779,13 @@ dependencies = [ [[package]] name = "dashcore-private" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" [[package]] name = "dashcore-rpc" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "dashcore-rpc-json", "hex", @@ -1797,8 +1797,8 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "bincode", "dashcore", @@ -1812,8 +1812,8 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "bincode", "dashcore-private", @@ -2866,8 +2866,8 @@ dependencies = [ [[package]] name = "git-state" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" [[package]] name = "glob" @@ -4033,8 +4033,8 @@ dependencies = [ [[package]] name = "key-wallet" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "aes", "async-trait", @@ -4062,8 +4062,8 @@ dependencies = [ [[package]] name = "key-wallet-ffi" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4078,8 +4078,8 @@ dependencies = [ [[package]] name = "key-wallet-manager" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=afcff1566c81abb0a9cf64b99b0ee675169fddfc#afcff1566c81abb0a9cf64b99b0ee675169fddfc" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index c7703f6b68..da7ec6e898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "afcff1566c81abb0a9cf64b99b0ee675169fddfc" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "afcff1566c81abb0a9cf64b99b0ee675169fddfc" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "afcff1566c81abb0a9cf64b99b0ee675169fddfc" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "afcff1566c81abb0a9cf64b99b0ee675169fddfc" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "afcff1566c81abb0a9cf64b99b0ee675169fddfc" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "afcff1566c81abb0a9cf64b99b0ee675169fddfc" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "afcff1566c81abb0a9cf64b99b0ee675169fddfc" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "afcff1566c81abb0a9cf64b99b0ee675169fddfc" } tokio-metrics = "0.5" diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index ade1182859..623272b853 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -39,23 +39,22 @@ //! # Zeroization //! //! Every intermediate that carries key material is wiped before the -//! method returns. Two mechanisms cover the different ownership +//! method returns. Three mechanisms cover the different ownership //! shapes: //! -//! - **`Zeroizing` wrappers** scrub on `Drop` for the byte-buffer -//! intermediates: the resolver mnemonic buffer, the BIP-39 seed, -//! and the final derived 32-byte scalar. -//! - **Explicit `non_secure_erase` calls** scrub the -//! [`secp256k1::SecretKey`] scalars inside the two intermediate -//! [`ExtendedPrivKey`] values (master + derived). `ExtendedPrivKey` -//! has no `Drop` / `Zeroize` impl in `key-wallet`, so falling out -//! of scope alone would leave those scalars resident; the explicit -//! wipe at the bottom of `derive_priv` closes the gap. Same -//! defense is applied at the sign-site for the `SecretKey` copy -//! `from_slice` creates. A proper fix is a `Zeroize` / -//! `ZeroizeOnDrop` impl in `dashpay/rust-dashcore`'s -//! `key-wallet/src/bip32.rs`; until that ships, the local wipes -//! keep the no-residue invariant true. +//! - **[`ExtendedPrivKey`] self-wipes on `Drop`.** The master and +//! derived extended keys zero their secret material when they leave +//! scope, on every exit path — success, `?`-early-return, and +//! panic-unwind. The type is not `Copy`, so each move is a real move +//! that leaves no stray bitwise duplicate behind. +//! - **`Zeroizing` wrappers** scrub the plain byte buffers that carry +//! no `Drop` of their own: the resolver mnemonic buffer, the BIP-39 +//! seed, and the final derived 32-byte scalar. +//! - **Explicit `non_secure_erase` calls** scrub the raw +//! [`secp256k1::SecretKey`] copies at the two sign sites, where the +//! scalar comes back out of `SecretKey::from_slice`. `SecretKey` has +//! no `Zeroize` impl (only `non_secure_erase()`), so it can't ride a +//! `Zeroizing` wrapper. //! //! Combined, no private key bytes survive past the trait-method //! boundary. @@ -64,9 +63,9 @@ use std::ffi::c_void; use std::os::raw::c_char; use async_trait::async_trait; -use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; +use key_wallet::bip32::{DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::secp256k1::{self, Secp256k1}; -use key_wallet::signer::{Signer, SignerMethod}; +use key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; use key_wallet::Network; use thiserror::Error; use zeroize::Zeroizing; @@ -222,18 +221,37 @@ impl MnemonicResolverCoreSigner { } } - /// Resolve the mnemonic from the Swift-side callback, then - /// derive the secp256k1 private key at `path`. Returns the raw - /// 32-byte scalar in a `Zeroizing` wrapper so the caller's last - /// drop point zeros it. + /// Resolve the mnemonic from the Swift-side callback, derive the BIP-32 + /// extended private key at `path`, and hand it *by reference* to + /// `extract`, returning whatever `extract` produces. /// - /// All other intermediate buffers (mnemonic, seed) are dropped - /// (and zeroed) before this method returns — only the final - /// derived scalar leaks out, and even that is `Zeroizing`-wrapped. - fn derive_priv( + /// This is the single entry-point for all private-key material in this + /// signer. It handles the full stack: resolver FFI call → result-code + /// mapping → UTF-8 + word-list validation → BIP-39 seed → master + /// `ExtendedPrivKey` → child `ExtendedPrivKey` at `path`. + /// + /// # Zeroization contract + /// + /// Both the `master` and `derived` extended keys wipe their secret + /// material when they leave this scope — [`ExtendedPrivKey`] zeroizes on + /// `Drop` and is not `Copy`, so each move is a real move that leaves no + /// bitwise duplicate behind. The + /// key never crosses the call boundary — `extract` only borrows it — so it + /// cannot outlive the derivation. `extract` returns public material + /// (`ExtendedPubKey`) or a `Zeroizing` scalar copy; the caller wipes the + /// latter on its own drop. The mnemonic and seed buffers are plain arrays + /// and ride [`Zeroizing`] wrappers for the same guarantee. + /// + /// # Errors + /// + /// Propagates [`MnemonicResolverSignerError`] for every failure mode: + /// null handle, resolver FFI errors, encoding/parse failures, and BIP-32 + /// derivation errors. + fn resolve_and_derive( &self, path: &DerivationPath, - ) -> Result, MnemonicResolverSignerError> { + extract: impl FnOnce(&ExtendedPrivKey) -> T, + ) -> Result { if self.resolver_addr == 0 { return Err(MnemonicResolverSignerError::NullHandle); } @@ -291,30 +309,30 @@ impl MnemonicResolverCoreSigner { drop(mnemonic); let secp = Secp256k1::new(); - let mut master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) + let master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")))?; - let mut derived = master + let derived = master .derive_priv(&secp, path) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; - // `secret_bytes()` returns a plain `[u8; 32]`; wrap in - // `Zeroizing` so the caller (and any panic-unwind path) - // wipes it on drop. - let bytes = Zeroizing::new(derived.private_key.secret_bytes()); - - // TODO(upstream): `key_wallet::bip32::ExtendedPrivKey` has no - // `Drop` / `Zeroize` impl — the inner `secp256k1::SecretKey` - // scalars on `master` and `derived` would otherwise drop - // un-wiped. Mirrors the SecretKey-copy hole CodeRabbit R7 - // flagged at the sign-site. Proper fix is a `Zeroize` / - // `ZeroizeOnDrop` impl in `dashpay/rust-dashcore`'s - // `key-wallet/src/bip32.rs`; until that lands, wipe the two - // SecretKey fields explicitly here. Mirrored in the sibling - // FFI at `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`. - master.private_key.non_secure_erase(); - derived.private_key.non_secure_erase(); - - Ok(bytes) + Ok(extract(&derived)) + } + + /// Resolve the mnemonic and derive the raw 32-byte scalar at `path`. + /// + /// Returns the scalar in a `Zeroizing` wrapper so the caller's last + /// drop point wipes it. The intermediate `ExtendedPrivKey` values, + /// mnemonic, and seed are wiped inside [`Self::resolve_and_derive`] + /// before this returns. + fn derive_priv( + &self, + path: &DerivationPath, + ) -> Result, MnemonicResolverSignerError> { + // `secret_bytes()` copies the scalar out of the borrowed key; the + // `ExtendedPrivKey` itself never leaves `resolve_and_derive`. + self.resolve_and_derive(path, |derived| { + Zeroizing::new(derived.private_key.secret_bytes()) + }) } } @@ -364,6 +382,27 @@ impl Signer for MnemonicResolverCoreSigner { } } +#[async_trait] +impl ExtendedPubKeySigner for MnemonicResolverCoreSigner { + /// Derive the BIP-32 extended public key at `path`. + /// + /// Returns the full [`ExtendedPubKey`] (public point + chain code) so + /// callers can perform non-hardened child derivation locally without + /// additional round-trips to the resolver. All intermediate private-key + /// material is zeroized before this method returns (see + /// [`Self::resolve_and_derive`]); `ExtendedPubKey` carries only public + /// information and requires no further wiping. + async fn extended_public_key( + &self, + path: &DerivationPath, + ) -> Result { + let secp = Secp256k1::new(); + // `ExtendedPubKey` carries only public material (chain code + point); + // the borrowed private key never leaves `resolve_and_derive`. + self.resolve_and_derive(path, |derived| ExtendedPubKey::from_priv(&secp, derived)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -456,6 +495,51 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + #[tokio::test] + async fn extended_public_key_matches_independent_derivation() { + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let path = test_path(); + let xpub = signer + .extended_public_key(&path) + .await + .expect("extended_public_key succeeds"); + + // Independently derive the expected xpub straight from the known + // BIP-39 vector — same network + path, no resolver in the loop. + let secp = Secp256k1::new(); + let mnemonic = parse_mnemonic_any_language(ENGLISH_PHRASE).expect("valid phrase"); + let master = ExtendedPrivKey::new_master(Network::Testnet, &mnemonic.to_seed("")) + .expect("master derivation"); + let derived = master.derive_priv(&secp, &path).expect("path derivation"); + let expected = ExtendedPubKey::from_priv(&secp, &derived); + + // Field-level checks run first so a silently-dropped BIP-32 metadatum + // fails here with a precise message — not just the public point. The + // final full-struct assert then catches the remaining fields + // (parent_fingerprint, child_number). Ordering matters: a leading + // full-struct `assert_eq!` would short-circuit and make these + // per-field asserts unreachable (i.e. vacuous) on a metadata regression. + assert_eq!( + xpub.public_key, expected.public_key, + "public key must match" + ); + assert_eq!( + xpub.chain_code, expected.chain_code, + "chain code must match" + ); + assert_eq!(xpub.depth, expected.depth, "depth must match"); + assert_eq!(xpub.network, expected.network, "network must match"); + assert_eq!( + xpub, expected, + "full xpub must match independent derivation" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + #[tokio::test] async fn missing_resolver_surfaces_not_found_error() { let resolver = make_resolver(missing_resolve); diff --git a/packages/rs-sdk-ffi/src/signer_simple.rs b/packages/rs-sdk-ffi/src/signer_simple.rs index 9b5ecd9ac9..1eb97522e5 100644 --- a/packages/rs-sdk-ffi/src/signer_simple.rs +++ b/packages/rs-sdk-ffi/src/signer_simple.rs @@ -414,9 +414,10 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_and_path( Ok(d) => d, Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_DERIVATION), }; - // Pull the 32 secret bytes into a `Zeroizing` so they're scrubbed - // when this function returns (the `ExtendedPrivKey` itself doesn't - // zeroize — `derived` falls out of scope intact). + // Copy the 32 secret bytes into a `Zeroizing` so this fresh array — + // which has no `Drop` of its own — is scrubbed when the function + // returns. `derived` self-wipes separately: `ExtendedPrivKey` + // zeroizes on `Drop`. let secret_bytes: zeroize::Zeroizing<[u8; 32]> = zeroize::Zeroizing::new(derived.private_key.secret_bytes());