Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/rs-platform-wallet-ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
167 changes: 167 additions & 0 deletions packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
"",
Expand Down Expand Up @@ -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);
}
}
}
7 changes: 7 additions & 0 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/rs-platform-wallet-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading