From ee023a79550a8ff6466ad48066294b2e639385ee Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 25 May 2026 12:19:09 +0200 Subject: [PATCH] fix(platform-wallet-ffi): wallet_id gate on resolver-fed sign entrypoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four FFI sign entrypoints take a MnemonicResolverHandle + wallet_id_bytes and derive signing material from the resolver-supplied mnemonic. None of them previously checked that the seed actually belongs to wallet_id_bytes — a host calling them with a mismatched (wallet_id, mnemonic) pair would happily derive and sign with the wrong key. This patch closes that gap: - New crate::sign_gate::verify_seed_matches_wallet_id(root_pub, expected_wallet_id) -> bool (constant-time via subtle::ConstantTimeEq, #[must_use]). - New PlatformWalletFFIResultCode::ErrorWrongSeedForWallet = 13 for the structured-result entrypoints. - New SIGN_WITH_RESOLVER_ERR_WRONG_SEED u8 tag for the byte-tagged path. Wired into: 1. sign_with_mnemonic_resolver::dash_sdk_sign_with_mnemonic_resolver_and_path 2. identity_derive_and_persist::dash_sdk_derive_and_persist_identity_keys 3. derive_identity_key_at_slot::dash_sdk_derive_identity_key_at_slot_with_resolver 4. shielded_sync::platform_wallet_manager_bind_shielded (shielded feature) Each call site: invokes the gate BEFORE any derive_priv / signing / persister callback / coordinator handoff; on mismatch zeroes any derived master key (master.private_key.non_secure_erase()), zeros any caller-owned output buffer (e.g. out_signature), and returns the typed tag — the persister callback is never reached, no signature bytes leak. Tests (co-located in each entrypoint's source under #[cfg(test)] mod tests): - sign_with_mnemonic_resolver: happy_path_signs_and_returns_signature + wrong_wallet_id_fails_closed_with_wrong_seed_tag (asserts every byte of sig_buf is zero on mismatch). - identity_derive_and_persist: wrong_wallet_id_fails_closed_before_persisting_anything (asserts persister callback row count is zero). - derive_identity_key_at_slot: matching_seed_returns_derived_key + wrong_wallet_id_fails_closed_with_wrong_seed_tag. - shielded_sync (gated on `shielded` feature): wrong_wallet_id_fails_closed + null_resolver_handle_rejected. - sign_gate::tests: helper unit tests for the CT comparison. Co-Authored-By: Claudius the Magnificent (1M context) --- Cargo.lock | 1 + packages/rs-platform-wallet-ffi/Cargo.toml | 4 + .../src/derive_identity_key_at_slot.rs | 167 ++++++++++++++++++ packages/rs-platform-wallet-ffi/src/error.rs | 7 + .../src/identity_derive_and_persist.rs | 79 ++++++++- packages/rs-platform-wallet-ffi/src/lib.rs | 1 + .../src/shielded_sync.rs | 153 ++++++++++++++++ .../rs-platform-wallet-ffi/src/sign_gate.rs | 75 ++++++++ .../src/sign_with_mnemonic_resolver.rs | 79 ++++++++- 9 files changed, 560 insertions(+), 6 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/sign_gate.rs diff --git a/Cargo.lock b/Cargo.lock index a2ae20b351f..b6e1e54953c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4869,6 +4869,7 @@ dependencies = [ "platform-wallet", "rs-sdk-ffi", "serde_json", + "subtle", "tempfile", "tokio", "tracing", diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index d99ef64db0d..1a4f066c741 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -54,6 +54,10 @@ bs58 = "0.5" # Zeroize intermediate key material crossing the FFI boundary. zeroize = { version = "1", features = ["derive"] } +# Constant-time `wallet_id` compare in `sign_gate`, mirroring the +# upstream gate previously in `manager::rehydrate`. +subtle = "2" + [dev-dependencies] tempfile = "3.8" dpp = { path = "../rs-dpp", features = ["fixtures-and-mocks"] } diff --git a/packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs b/packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs index 7b902a0410d..8779d261607 100644 --- a/packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs +++ b/packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs @@ -12,7 +12,10 @@ use zeroize::Zeroizing; use crate::error::*; use crate::identity_key_preview::IdentityKeyPreviewFFI; use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; +use crate::sign_gate::verify_seed_matches_wallet_id; use crate::{check_ptr, unwrap_result_or_return}; +use dashcore::secp256k1::Secp256k1; +use key_wallet::bip32::ExtendedPubKey; use rs_sdk_ffi::{ mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, }; @@ -182,6 +185,32 @@ pub unsafe extern "C" fn dash_sdk_derive_identity_key_at_slot_with_resolver( let mnemonic_str = unwrap_result_or_return!(std::str::from_utf8(&mnemonic_buf[..mnemonic_len])); + // Fail-closed wrong-seed gate before any signing-relevant material + // is materialized for the caller. Derive the root xpub from the + // resolver-supplied seed in its own scope so the master xpriv + + // its SecretKey are non-secure-erased before the inner derivation + // path runs (which re-derives from the seed via the shared helper). + { + let mnemonic = unwrap_result_or_return!(parse_mnemonic_any_language(mnemonic_str)); + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + drop(mnemonic); + let kw_network: Network = network.into(); + let mut master = + unwrap_result_or_return!(ExtendedPrivKey::new_master(kw_network, seed.as_ref())); + let secp = Secp256k1::new(); + let root_xpub = ExtendedPubKey::from_priv(&secp, &master); + let mut wallet_id_expected = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id_expected.as_mut_ptr(), 32); + let gate_ok = verify_seed_matches_wallet_id(&root_xpub, &wallet_id_expected); + master.private_key.non_secure_erase(); + if !gate_ok { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWrongSeedForWallet, + "wrong seed for wallet (derive_identity_key_at_slot_with_resolver gate)", + ); + } + } + derive_at_slot_inner( mnemonic_str, "", @@ -226,3 +255,141 @@ pub unsafe extern "C" fn dash_sdk_derive_identity_key_at_slot_free( } row.identity_index = 0; } + +#[cfg(test)] +mod tests { + use super::*; + use key_wallet::wallet::root_extended_keys::RootExtendedPubKey; + use key_wallet::wallet::Wallet; + use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; + + /// English BIP-39 test vector (all-zero entropy). Matches the + /// fixture in the sibling resolver-fed entrypoints. + const ENGLISH_PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + /// Compute the `wallet_id` the gate will recompute from the + /// resolver-supplied seed. Tests must pass this id for happy-path + /// or short-circuit with `ErrorWrongSeedForWallet` on mismatch. + fn wallet_id_for_english_phrase() -> [u8; 32] { + let mnemonic = parse_mnemonic_any_language(ENGLISH_PHRASE).unwrap(); + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + let mut master = + ExtendedPrivKey::new_master(key_wallet::Network::Testnet, seed.as_ref()).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + let root = RootExtendedPubKey::from_extended_pub_key(&xpub); + let id = Wallet::compute_wallet_id_from_root_extended_pub_key(&root); + master.private_key.non_secure_erase(); + id + } + + unsafe extern "C" fn english_resolve( + _ctx: *const c_void, + _wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + ) -> i32 { + let phrase = ENGLISH_PHRASE.as_bytes(); + if phrase.len() + 1 > out_capacity { + return mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + + fn make_resolver() -> *mut MnemonicResolverHandle { + unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), english_resolve, noop_destroy) + } + } + + /// Happy path: resolver yields a mnemonic whose derived wallet_id + /// matches the caller-supplied one; the gate passes and a non-empty + /// derived keypair is returned in `out_row`. + #[test] + fn matching_seed_returns_derived_key() { + let resolver = make_resolver(); + let wallet_id = wallet_id_for_english_phrase(); + let mut out_row = IdentityKeyPreviewFFI::empty(); + let rc = unsafe { + dash_sdk_derive_identity_key_at_slot_with_resolver( + FFINetwork::Testnet, + wallet_id.as_ptr(), + resolver, + 5, // identity_index + 0, // key_index + &mut out_row, + ) + }; + assert_eq!(rc.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out_row.identity_index, 5); + assert!(!out_row.derivation_path.is_null()); + assert!(!out_row.public_key.is_null()); + assert_eq!(out_row.public_key_len, 33, "compressed secp256k1 pubkey"); + assert!(!out_row.private_key_wif.is_null()); + // private_key_bytes must be populated (non-all-zero with + // overwhelming probability for a real derivation). + assert!( + out_row.private_key_bytes.iter().any(|b| *b != 0), + "derived private scalar should not be all zeros" + ); + + // Path shape: testnet coin = 1, identity_index = 5, key_index = 0. + let path = unsafe { std::ffi::CStr::from_ptr(out_row.derivation_path) }.to_string_lossy(); + assert_eq!(path, "m/9'/1'/5'/0'/0'/5'/0'"); + + unsafe { + dash_sdk_derive_identity_key_at_slot_free(&mut out_row); + dash_sdk_mnemonic_resolver_destroy(resolver); + } + } + + /// Wrong-seed gate fires: resolver yields a valid mnemonic but the + /// caller-supplied `wallet_id` doesn't match its derived id. + /// `out_row` must be left at its zeroed-empty state and no derived + /// key material may leak through it. + #[test] + fn wrong_wallet_id_fails_closed_with_wrong_seed_tag() { + let resolver = make_resolver(); + // A wallet_id that cannot match the abandon-x12 derived id. + let wrong_wallet_id = [0xAAu8; 32]; + let mut out_row = IdentityKeyPreviewFFI::empty(); + let rc = unsafe { + dash_sdk_derive_identity_key_at_slot_with_resolver( + FFINetwork::Testnet, + wrong_wallet_id.as_ptr(), + resolver, + 0, + 0, + &mut out_row, + ) + }; + assert_eq!( + rc.code, + PlatformWalletFFIResultCode::ErrorWrongSeedForWallet, + "wrong-seed gate must fire with the dedicated structural tag" + ); + // Caller-owned output struct must be the zero/empty state — no + // derivation_path, no pubkey, no WIF, no private scalar bytes. + assert!(out_row.derivation_path.is_null()); + assert!(out_row.public_key.is_null()); + assert_eq!(out_row.public_key_len, 0); + assert!(out_row.private_key_wif.is_null()); + for b in out_row.private_key_bytes { + assert_eq!(b, 0, "private_key_bytes must be fully zeroed"); + } + assert_eq!(out_row.identity_index, 0); + + unsafe { + // Defensive: free is null-tolerant on the empty row. + dash_sdk_derive_identity_key_at_slot_free(&mut out_row); + dash_sdk_mnemonic_resolver_destroy(resolver); + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 9fb32fba017..199c3774cae 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -89,6 +89,13 @@ pub enum PlatformWalletFFIResultCode { /// receive address, consolidate sub-min balances, or fall back to /// `InputSelection::Explicit`. ErrorNoSelectableInputs = 14, + /// Fail-closed wrong-seed gate hit: the seed yielded by the + /// `MnemonicResolverHandle` does not derive the `wallet_id` the + /// caller passed in. Surfaced by every resolver-fed sign / derive + /// entrypoint. Constant-time compared via + /// [`crate::sign_gate::verify_seed_matches_wallet_id`]; no key + /// material crosses this surface. + ErrorWrongSeedForWallet = 15, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, diff --git a/packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs b/packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs index 01bff146ef5..179b49300f3 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs @@ -91,6 +91,7 @@ use crate::identity_keys_from_mnemonic::{ identity_auth_derivation_path, parse_mnemonic_any_language, }; use crate::identity_registration_with_signer::IdentityRegistrationKeyDerivationsFFI; +use crate::sign_gate::verify_seed_matches_wallet_id; use crate::{check_ptr, unwrap_result_or_return}; use rs_sdk_ffi::{ mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, @@ -240,9 +241,23 @@ pub unsafe extern "C" fn dash_sdk_derive_and_persist_identity_keys( drop(mnemonic); let kw_network: Network = network.into(); - let master = unwrap_result_or_return!(ExtendedPrivKey::new_master(kw_network, seed.as_ref())); + let mut master = + unwrap_result_or_return!(ExtendedPrivKey::new_master(kw_network, seed.as_ref())); let secp = Secp256k1::new(); + // Fail-closed wrong-seed gate (constant-time compare). Zeroize the + // master xpriv's SecretKey before bailing on mismatch. + let root_xpub = ExtendedPubKey::from_priv(&secp, &master); + let mut wallet_id_expected = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id_expected.as_mut_ptr(), 32); + if !verify_seed_matches_wallet_id(&root_xpub, &wallet_id_expected) { + master.private_key.non_secure_erase(); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWrongSeedForWallet, + "wrong seed for wallet (derive_and_persist gate)", + ); + } + // ---- Walk derivation paths, persist, build pubkey-only rows -------------- let persister = &*persister_handle; let persister_vtable = &*persister.vtable; @@ -416,6 +431,25 @@ mod tests { const ENGLISH_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + /// Compute the `wallet_id` the wrong-seed gate will recompute from + /// the resolver-supplied seed. Tests must pass this id (or another + /// that derives from the same phrase) — anything else short-circuits + /// before persistence with `ErrorWrongSeedForWallet`. + fn wallet_id_for_english_phrase() -> [u8; 32] { + use key_wallet::wallet::root_extended_keys::RootExtendedPubKey; + use key_wallet::wallet::Wallet; + let mnemonic = parse_mnemonic_any_language(ENGLISH_PHRASE).unwrap(); + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + let mut master = + ExtendedPrivKey::new_master(key_wallet::Network::Testnet, seed.as_ref()).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + let root = RootExtendedPubKey::from_extended_pub_key(&xpub); + let id = Wallet::compute_wallet_id_from_root_extended_pub_key(&root); + master.private_key.non_secure_erase(); + id + } + #[derive(Debug, Clone)] struct CapturedPersist { identity_index: u32, @@ -547,7 +581,7 @@ mod tests { #[test] fn happy_path_persists_three_keys_and_returns_pubkeys() { let (resolver, persister, capture) = make_capturing_handles(); - let wallet_id = [42u8; 32]; + let wallet_id = wallet_id_for_english_phrase(); let mut out = IdentityRegistrationKeyDerivationsFFI { items: std::ptr::null_mut(), count: 0, @@ -611,7 +645,7 @@ mod tests { #[test] fn returning_false_from_persister_aborts_with_wallet_op_error() { let (resolver, persister) = make_failing_handles(); - let wallet_id = [1u8; 32]; + let wallet_id = wallet_id_for_english_phrase(); let mut out = IdentityRegistrationKeyDerivationsFFI { items: std::ptr::null_mut(), count: 0, @@ -678,6 +712,45 @@ mod tests { } } + #[test] + fn wrong_wallet_id_fails_closed_before_persisting_anything() { + let (resolver, persister, capture) = make_capturing_handles(); + // A wallet_id that cannot match the abandon-x12 derived id. + let wrong_wallet_id = [0xAAu8; 32]; + let mut out = IdentityRegistrationKeyDerivationsFFI { + items: std::ptr::null_mut(), + count: 0, + }; + let rc = unsafe { + dash_sdk_derive_and_persist_identity_keys( + FFINetwork::Testnet, + wrong_wallet_id.as_ptr(), + 0, + 3, + resolver, + persister, + &mut out, + ) + }; + assert_eq!( + rc.code, + PlatformWalletFFIResultCode::ErrorWrongSeedForWallet + ); + assert_eq!(out.count, 0); + assert!(out.items.is_null()); + // The persister callback must NOT have been hit. + assert_eq!( + unsafe { (*capture).rows.lock().unwrap().len() }, + 0, + "wrong-seed gate must short-circuit before any key is persisted" + ); + unsafe { + dash_sdk_mnemonic_resolver_destroy(resolver); + dash_sdk_identity_key_persister_destroy(persister); + let _ = Box::from_raw(capture); + } + } + #[test] fn key_count_zero_is_success_no_calls() { let (resolver, persister, capture) = make_capturing_handles(); diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 6f770ed142d..129058c327a 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -59,6 +59,7 @@ pub mod shielded_send; #[cfg(feature = "shielded")] pub mod shielded_sync; pub mod shielded_types; +pub mod sign_gate; pub mod sign_with_mnemonic_resolver; pub mod spv; pub mod token_persistence; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 3f152059c87..7a4b6ea1a16 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -22,7 +22,11 @@ use crate::handle::*; use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; use crate::runtime::runtime; use crate::shielded_types::ShieldedSyncWalletResultFFI; +use crate::sign_gate::verify_seed_matches_wallet_id; +use crate::types::Network; use crate::{check_ptr, unwrap_option_or_return}; +use dashcore::secp256k1::Secp256k1; +use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; use rs_sdk_ffi::{ mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, }; @@ -302,6 +306,33 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); drop(mnemonic); + // Fail-closed wrong-seed gate (constant-time compare). Runs before + // any signing material is materialized downstream. The wallet_id + // is independent of `kw_network` here (it hashes only pubkey + + // chain code), so any network is acceptable for the throwaway + // master used to project the root xpub. + { + let mut gate_master = match ExtendedPrivKey::new_master(Network::Testnet, seed.as_ref()) { + Ok(m) => m, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("derive gate-master failed: {e}"), + ); + } + }; + let secp = Secp256k1::new(); + let root_xpub = ExtendedPubKey::from_priv(&secp, &gate_master); + let gate_ok = verify_seed_matches_wallet_id(&root_xpub, &wallet_id); + gate_master.private_key.non_secure_erase(); + if !gate_ok { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWrongSeedForWallet, + "wrong seed for wallet (bind_shielded gate)", + ); + } + } + // Look up the wallet + the network-scoped shielded coordinator // on the manager. The coordinator owns the single SQLite handle // *and* the per-network sync-coordination registry; we hand it @@ -548,3 +579,125 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_wallet( ), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::handle::NULL_HANDLE; + use key_wallet::wallet::root_extended_keys::RootExtendedPubKey; + use key_wallet::wallet::Wallet; + use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; + + /// English BIP-39 test vector (all-zero entropy). Matches the + /// sibling resolver-fed entrypoints' fixture. + const ENGLISH_PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + /// `wallet_id` the gate will recompute from the resolver-supplied + /// seed. Anything but this id short-circuits with + /// `ErrorWrongSeedForWallet` before any manager / coordinator + /// state is touched. + #[allow(dead_code)] // referenced by future happy-path coverage; see module note below. + fn wallet_id_for_english_phrase() -> [u8; 32] { + use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; + let mnemonic = parse_mnemonic_any_language(ENGLISH_PHRASE).unwrap(); + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + let mut master = + ExtendedPrivKey::new_master(key_wallet::Network::Testnet, seed.as_ref()).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + let root = RootExtendedPubKey::from_extended_pub_key(&xpub); + let id = Wallet::compute_wallet_id_from_root_extended_pub_key(&root); + master.private_key.non_secure_erase(); + id + } + + unsafe extern "C" fn english_resolve( + _ctx: *const std::os::raw::c_void, + _wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + ) -> i32 { + let phrase = ENGLISH_PHRASE.as_bytes(); + if phrase.len() + 1 > out_capacity { + return mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut std::os::raw::c_void) {} + + fn make_resolver() -> *mut MnemonicResolverHandle { + unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), english_resolve, noop_destroy) + } + } + + /// Wrong-seed gate fires before the manager handle is dereferenced. + /// The resolver yields a valid mnemonic but the caller-supplied + /// `wallet_id` doesn't match its derived id, so: + /// + /// (a) the FFI returns `ErrorWrongSeedForWallet`, + /// (b) `NULL_HANDLE` is never consulted on `PLATFORM_WALLET_MANAGER_STORAGE` + /// — proves no shielded state can be bound on a mismatch, + /// (c) the `gate_master` xpriv inside the gate scope is + /// `non_secure_erase`d before the early return (verified by the + /// gate code path itself; this test exercises that path). + /// + /// **Happy path note**: `bind_shielded`'s happy path requires a + /// fully-constructed `PlatformWalletManager` (Sdk, persistence + /// vtable, event handler), a prior `configure_shielded` (SQLite + /// commitment-tree file open), and a registered wallet whose id + /// matches the gate. All three pieces are heavy infra to stand + /// up from a single unit test and would duplicate coverage that + /// already lives in `platform-wallet`'s shielded integration + /// suite. Per Marvin's directive (test the gate's effect in + /// isolation), we exercise the mismatch path only here — the + /// resolver-derived seed never reaches the coordinator, the + /// caller sees the canonical structural tag, and the manager + /// storage is never touched. + #[test] + fn wrong_wallet_id_fails_closed() { + let resolver = make_resolver(); + let wrong_wallet_id = [0xAAu8; 32]; + let accounts: [u32; 1] = [0]; + let rc = unsafe { + platform_wallet_manager_bind_shielded( + NULL_HANDLE, + wrong_wallet_id.as_ptr(), + resolver, + accounts.as_ptr(), + accounts.len(), + ) + }; + assert_eq!( + rc.code, + PlatformWalletFFIResultCode::ErrorWrongSeedForWallet, + "wrong-seed gate must short-circuit before the manager lookup" + ); + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// Defensive: a NULL resolver handle must be rejected before the + /// resolver callback is ever fired and before the gate runs. + /// Locks in `check_ptr!` ordering for the bind_shielded entrypoint. + #[test] + fn null_resolver_handle_rejected() { + let wallet_id = [0xAAu8; 32]; + let accounts: [u32; 1] = [0]; + let rc = unsafe { + platform_wallet_manager_bind_shielded( + NULL_HANDLE, + wallet_id.as_ptr(), + std::ptr::null_mut(), + accounts.as_ptr(), + accounts.len(), + ) + }; + assert_eq!(rc.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/sign_gate.rs b/packages/rs-platform-wallet-ffi/src/sign_gate.rs new file mode 100644 index 00000000000..ca8aa0ab41a --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/sign_gate.rs @@ -0,0 +1,75 @@ +//! Shared fail-closed `wallet_id` gate for resolver-fed sign entrypoints. +//! +//! Every FFI entrypoint that takes a `MnemonicResolverHandle` plus an +//! `expected wallet_id` and derives signing keys from the resolved +//! mnemonic MUST run the gate immediately after deriving the root +//! extended public key, *before* it touches `derive_priv` (and well +//! before any signature material is emitted). +//! +//! The gate constant-time compares the loaded `wallet_id` against the +//! one recomputed from the resolver-supplied root extended public key. +//! On mismatch it returns the structural failure tag the caller hands +//! to its error path — no rendered hex, no key bytes. +//! +//! This is the fail-closed counterpart to the seedless load path: +//! `load_from_persistor` reconstructs each persisted wallet +//! **watch-only** (no derivation, no gate); wrong-seed detection moves +//! here, where the gate is meaningful (the resolver actually produced +//! signing material). + +use key_wallet::bip32::ExtendedPubKey; +use key_wallet::wallet::root_extended_keys::RootExtendedPubKey; +use key_wallet::wallet::Wallet; +use subtle::ConstantTimeEq; + +/// Recompute `wallet_id` from the supplied root extended public key and +/// constant-time compare against `expected_wallet_id`. +/// +/// Returns `true` on match, `false` on mismatch. The boolean is +/// intentionally information-free beyond the verdict: the caller maps +/// `false` onto its own structural failure tag +/// (`SIGN_WITH_RESOLVER_ERR_WRONG_SEED` and friends). No key material +/// crosses this surface in either direction. +#[inline] +#[must_use] +pub fn verify_seed_matches_wallet_id( + root_pub: &ExtendedPubKey, + expected_wallet_id: &[u8; 32], +) -> bool { + let root_extended = RootExtendedPubKey::from_extended_pub_key(root_pub); + let derived = Wallet::compute_wallet_id_from_root_extended_pub_key(&root_extended); + bool::from(derived.ct_eq(expected_wallet_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::secp256k1::Secp256k1; + use key_wallet::bip32::ExtendedPrivKey; + + fn root_xpub_for_seed(seed: &[u8; 64]) -> (ExtendedPubKey, [u8; 32]) { + let master = + ExtendedPrivKey::new_master(key_wallet::Network::Testnet, seed.as_ref()).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + let root = RootExtendedPubKey::from_extended_pub_key(&xpub); + let wid = Wallet::compute_wallet_id_from_root_extended_pub_key(&root); + (xpub, wid) + } + + #[test] + fn matching_seed_passes() { + let seed = [0x11; 64]; + let (xpub, wid) = root_xpub_for_seed(&seed); + assert!(verify_seed_matches_wallet_id(&xpub, &wid)); + } + + #[test] + fn mismatched_seed_fails() { + let seed_a = [0x11; 64]; + let seed_b = [0x22; 64]; + let (xpub_a, _) = root_xpub_for_seed(&seed_a); + let (_, wid_b) = root_xpub_for_seed(&seed_b); + assert!(!verify_seed_matches_wallet_id(&xpub_a, &wid_b)); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs b/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs index 75045ff915e..8ac09b1c015 100644 --- a/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs +++ b/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs @@ -42,10 +42,11 @@ use std::str::FromStr; use crate::types::{FFINetwork, Network}; use dashcore::secp256k1::Secp256k1; -use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; +use key_wallet::bip32::{DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use zeroize::Zeroizing; use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; +use crate::sign_gate::verify_seed_matches_wallet_id; use rs_sdk_ffi::{ mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, }; @@ -67,6 +68,10 @@ pub const SIGN_WITH_RESOLVER_ERR_UNSUPPORTED_KEY_TYPE: u8 = 8; pub const SIGN_WITH_RESOLVER_ERR_RESOLVER_NOT_FOUND: u8 = 9; /// Resolver callback returned `mnemonic_resolver_result::OTHER`. pub const SIGN_WITH_RESOLVER_ERR_RESOLVER_FAILED: u8 = 10; +/// The seed yielded by the resolver does NOT derive the +/// `wallet_id_bytes` passed by the caller. Fail-closed wrong-seed +/// gate (constant-time compare). No key material crosses this surface. +pub const SIGN_WITH_RESOLVER_ERR_WRONG_SEED: u8 = 11; /// Sign `data` with the ECDSA secp256k1 private key derived from /// `(mnemonic-via-resolver, derivation_path)`. Mnemonic, seed and @@ -205,6 +210,19 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_resolver_and_path( Err(_) => return fail(SIGN_WITH_RESOLVER_ERR_DERIVATION), }; let secp = Secp256k1::new(); + + // Fail-closed wrong-seed gate (constant-time compare). The seedless + // load path no longer runs this gate at load time; it lives here, + // on the first sign call that the resolver-supplied seed actually + // feeds into a derivation. Zeroize derived material before bailing. + let root_xpub = ExtendedPubKey::from_priv(&secp, &master); + let mut wallet_id_expected = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id_expected.as_mut_ptr(), 32); + if !verify_seed_matches_wallet_id(&root_xpub, &wallet_id_expected) { + master.private_key.non_secure_erase(); + return fail(SIGN_WITH_RESOLVER_ERR_WRONG_SEED); + } + let mut derived = match master.derive_priv(&secp, &path) { Ok(d) => d, Err(_) => return fail(SIGN_WITH_RESOLVER_ERR_DERIVATION), @@ -248,6 +266,8 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_resolver_and_path( #[cfg(test)] mod tests { use super::*; + use key_wallet::wallet::root_extended_keys::RootExtendedPubKey; + use key_wallet::wallet::Wallet; use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; use std::ffi::CString; @@ -255,6 +275,21 @@ mod tests { const ENGLISH_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + /// Recompute the loaded `wallet_id` the resolver-derived seed will be + /// gated against in the happy-path test. + fn wallet_id_for_english_phrase() -> [u8; 32] { + let mnemonic = parse_mnemonic_any_language(ENGLISH_PHRASE).unwrap(); + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + let mut master = + ExtendedPrivKey::new_master(key_wallet::Network::Testnet, seed.as_ref()).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + let root = RootExtendedPubKey::from_extended_pub_key(&xpub); + let id = Wallet::compute_wallet_id_from_root_extended_pub_key(&root); + master.private_key.non_secure_erase(); + id + } + unsafe extern "C" fn english_resolve( _ctx: *const c_void, _wallet_id_bytes: *const u8, @@ -292,7 +327,7 @@ mod tests { fn happy_path_signs_and_returns_signature() { let resolver = make_resolver(english_resolve); let path = CString::new("m/9'/1'/5'/0'/0'/0'/0'").unwrap(); - let wallet_id = [0u8; 32]; + let wallet_id = wallet_id_for_english_phrase(); let data = b"hello"; let mut sig_buf = [0u8; 128]; let mut sig_len: usize = 0; @@ -349,11 +384,49 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + /// Resolver yields a valid mnemonic but the loaded `wallet_id` + /// doesn't match its derived id: the wrong-seed gate must fire, + /// `out_signature_len = 0`, no bytes in `out_signature`. + #[test] + fn wrong_wallet_id_fails_closed_with_wrong_seed_tag() { + let resolver = make_resolver(english_resolve); + let path = CString::new("m/9'/1'/5'/0'/0'/0'/0'").unwrap(); + // A wallet_id derived from a different seed (here: just a + // sentinel that cannot equal the abandon-x12 wallet_id). + let wrong_wallet_id = [0xAAu8; 32]; + let data = b"x"; + let mut sig_buf = [0xFFu8; 128]; + let mut sig_len: usize = 1; + let mut err: u8 = 0; + let rc = unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wrong_wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + 0, + FFINetwork::Testnet, + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len, + &mut err, + ) + }; + assert_eq!(rc, -1); + assert_eq!(err, SIGN_WITH_RESOLVER_ERR_WRONG_SEED); + assert_eq!(sig_len, 0, "no signature length must be reported"); + for b in sig_buf { + assert_eq!(b, 0, "out_signature buffer must be fully zeroed"); + } + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + #[test] fn rejects_unsupported_key_type() { let resolver = make_resolver(english_resolve); let path = CString::new("m/9'/1'/5'/0'/0'/0'/0'").unwrap(); - let wallet_id = [0u8; 32]; + let wallet_id = wallet_id_for_english_phrase(); let data = b"x"; let mut sig_buf = [0u8; 128]; let mut sig_len: usize = 0;