diff --git a/packages/rs-platform-wallet-ffi/src/address_private_key.rs b/packages/rs-platform-wallet-ffi/src/address_private_key.rs new file mode 100644 index 00000000000..31b801db797 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/address_private_key.rs @@ -0,0 +1,242 @@ +//! FFI binding for revealing the private key of a single core +//! (Layer-1) address — [`platform_wallet_address_private_key`]. +//! +//! Thin marshalling over +//! [`PlatformWallet::derive_core_address_private_key`](platform_wallet::PlatformWallet::derive_core_address_private_key): +//! the whole address-lookup-and-derive decision lives on the Rust +//! library side. This shim only resolves the mnemonic (for the +//! external-signable / watch-only wallets the iOS app holds, whose seed +//! lives in the iOS Keychain rather than the `WalletManager`) and +//! marshals the resulting hex + WIF strings across the C ABI. +//! +//! # Key source: chosen by wallet capability +//! +//! Mirrors the sibling +//! [`platform_wallet_preview_identity_registration_keys`](crate::platform_wallet_preview_identity_registration_keys): +//! a [`MnemonicResolverHandle`] is a *capability*, consulted only when +//! the in-process wallet lacks resident private keys. +//! +//! - **Resident-key wallet** (created from a raw seed): the resolver is +//! never touched and the key is derived from the in-process wallet. +//! - **External-signable / watch-only wallet** (the iOS shape): the +//! mnemonic is resolved on demand (keyed by the wallet handle's own +//! `wallet_id`), a master [`ExtendedPrivKey`] is built, the address's +//! key is derived from it, and the master's scalar is wiped before +//! returning. If the resolver handle is null for such a wallet the +//! call errors. +//! +//! The two-phase locking rule from the identity preview applies here +//! too: the wallet-manager read guard is NEVER held across the Swift +//! resolver callback. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; + +use key_wallet::bip32::ExtendedPrivKey; +use platform_wallet::CoreAddressPrivateKey; +use zeroize::{Zeroize, Zeroizing}; + +use crate::error::*; +use crate::handle::*; +use crate::identity_keys_from_mnemonic::resolve_master_from_resolver; +use crate::types::Network; +use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use rs_sdk_ffi::MnemonicResolverHandle; + +/// The private key for one core address, in the two forms the host +/// renders: lowercase hex (64 chars) and network-aware compressed WIF. +/// +/// Both strings are heap-allocated and owned by Rust until released by +/// [`platform_wallet_address_private_key_free`], which zeroizes their +/// backing bytes before freeing. +#[repr(C)] +pub struct AddressPrivateKeyFFI { + /// Null-terminated lowercase hex of the raw 32-byte secp256k1 + /// scalar (64 hex chars). Null on the pre-cleared / freed state. + pub private_key_hex: *mut c_char, + /// Null-terminated WIF (Wallet Import Format) string — network-aware + /// (mainnet vs testnet/devnet/regtest version byte) and compressed. + pub private_key_wif: *mut c_char, +} + +impl AddressPrivateKeyFFI { + /// All-null row so a failed call leaves the caller looking at known + /// empty state instead of uninitialized memory. + fn empty() -> Self { + Self { + private_key_hex: std::ptr::null_mut(), + private_key_wif: std::ptr::null_mut(), + } + } +} + +/// Derive the private key for `address_cstr`, one of this wallet's +/// tracked core addresses, and return it as hex + WIF. +/// +/// # Parameters +/// - `wallet_handle` — platform-wallet handle. +/// - `mnemonic_resolver_handle` — Swift-owned [`MnemonicResolverHandle`], +/// consulted only when the in-process wallet lacks resident private +/// keys (see the module docs). May be null for resident-key wallets. +/// - `address_cstr` — the address string to reveal the key for. +/// - `out` — populated on success. Release with +/// [`platform_wallet_address_private_key_free`]. Left at the empty +/// zero state on error. +/// +/// Returns a [`PlatformWalletFFIResult`]; `NotFound` for an unknown +/// handle, and the typed wallet-error code (with a descriptive message) +/// when the address is not tracked by the wallet or derivation fails. +/// +/// # Safety +/// `wallet_handle` must come from the platform-wallet handle registry. +/// `mnemonic_resolver_handle`, when non-null, must come from +/// [`rs_sdk_ffi::dash_sdk_mnemonic_resolver_create`] and remain valid +/// for the duration of the call. `address_cstr` must be a valid +/// NUL-terminated UTF-8 C-string, and `out` a valid writable pointer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_address_private_key( + wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + address_cstr: *const c_char, + out: *mut AddressPrivateKeyFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out); + unsafe { *out = AddressPrivateKeyFFI::empty() }; + check_ptr!(address_cstr); + + let address_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(address_cstr) }.to_str()); + + let option = PLATFORM_WALLET_STORAGE.with_item( + wallet_handle, + |wallet| -> Result { + let network: Network = wallet.network(); + + // Phase 1 — capability probe under a SHORT read guard, + // dropped before any resolver interaction. + let is_resident = { + let wm = wallet.wallet_manager().blocking_read(); + match wm.get_wallet(&wallet.wallet_id()) { + Some(key_wallet) => { + !key_wallet.is_external_signable() && !key_wallet.is_watch_only() + } + None => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "wallet not found in wallet manager", + )); + } + } + }; + + // Phase 2 — resolve the master xpriv for external-signable / + // watch-only wallets. NEVER under the guard above: the + // resolver synchronously re-enters Swift and reads the iOS + // Keychain (which can stall on biometric unlock). + let mut master_opt: Option = None; + if !is_resident { + if mnemonic_resolver_handle.is_null() { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "this wallet has no resident private keys (external-signable / \ + watch-only); a mnemonic resolver handle is required to reveal \ + an address private key", + )); + } + let wallet_id = wallet.wallet_id(); + // SAFETY: handle is non-null (checked) and the caller's + // safety contract guarantees it came from + // `dash_sdk_mnemonic_resolver_create`. + master_opt = Some(unsafe { + resolve_master_from_resolver(mnemonic_resolver_handle, &wallet_id, network)? + }); + } + + // Phase 3 — library lookup + derive (re-acquires the guard + // internally; the resolver, if any, has already run). + let result = wallet.derive_core_address_private_key(address_str, master_opt.as_ref()); + + // Wipe the resolved master's inner scalar — `ExtendedPrivKey` + // has no `Drop` / `Zeroize`. No-op on the resident path. + if let Some(mut master) = master_opt { + master.private_key.non_secure_erase(); + } + + result.map_err(PlatformWalletFFIResult::from) + }, + ); + let result = unwrap_option_or_return!(option); + let secret = unwrap_result_or_return!(result); + + // Move the sensitive fields out and marshal them through + // `secret_string_into_raw`, which scrubs every transient plaintext + // copy (see its docs — a naive `CString::new` over the + // zero-spare-capacity hex / WIF buffers would realloc and free the + // original un-zeroized). `private_key` (Zeroizing) is wiped when it + // drops at the end of this function; the returned buffers are + // zeroized by `_free`. + let CoreAddressPrivateKey { + private_key, wif, .. + } = secret; + let hex_c = unwrap_result_or_return!(secret_string_into_raw(Zeroizing::new(hex::encode( + &private_key[..] + )))); + let wif_c = unwrap_result_or_return!(secret_string_into_raw(Zeroizing::new(wif))); + + unsafe { + *out = AddressPrivateKeyFFI { + private_key_hex: hex_c, + private_key_wif: wif_c, + }; + } + PlatformWalletFFIResult::ok() +} + +/// Move a secret string onto the heap as a NUL-terminated C-string +/// without leaving an un-zeroized plaintext copy behind. +/// +/// `CString::new` must append a NUL terminator; handed a +/// zero-spare-capacity buffer — which both `hex::encode` and +/// `PrivateKey::to_wif` produce (`len == capacity`) — its internal +/// `reserve_exact(1)` reallocates and frees the original allocation +/// **without** zeroizing it, stranding the plaintext secret in reclaimed +/// heap. We copy into a buffer pre-sized with room for the NUL so +/// `CString::new` cannot realloc, and wipe the `Zeroizing` source on +/// drop. The only surviving plaintext is then the returned buffer, which +/// the matching `_free` zeroizes. +pub(crate) fn secret_string_into_raw( + secret: Zeroizing, +) -> Result<*mut c_char, std::ffi::NulError> { + let mut buf = Vec::with_capacity(secret.len() + 1); + buf.extend_from_slice(secret.as_bytes()); + // `secret` wiped here on drop; `buf` (len N, capacity ≥ N+1) moves + // into the CString with no realloc, so no plaintext copy is stranded. + drop(secret); + Ok(CString::new(buf)?.into_raw()) +} + +/// Release an [`AddressPrivateKeyFFI`] populated by +/// [`platform_wallet_address_private_key`], zeroizing the sensitive hex +/// and WIF backing bytes before freeing. Safe on a null outer pointer or +/// an already-freed / empty struct (no-op). Fields are nulled after free +/// so a second call is idempotent. +/// +/// # Safety +/// `out`'s pointers must have been produced by +/// [`platform_wallet_address_private_key`] and must not be freed twice. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_address_private_key_free(out: *mut AddressPrivateKeyFFI) { + if out.is_null() { + return; + } + let out = unsafe { &mut *out }; + if !out.private_key_hex.is_null() { + let mut bytes = unsafe { CString::from_raw(out.private_key_hex) }.into_bytes_with_nul(); + bytes.zeroize(); + out.private_key_hex = std::ptr::null_mut(); + } + if !out.private_key_wif.is_null() { + let mut bytes = unsafe { CString::from_raw(out.private_key_wif) }.into_bytes_with_nul(); + bytes.zeroize(); + out.private_key_wif = std::ptr::null_mut(); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index a2b0bf8aa76..0d4adcf512e 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -9,6 +9,7 @@ #![allow(clippy::result_large_err)] #![allow(clippy::large_enum_variant)] +pub mod address_private_key; pub mod asset_lock; pub mod asset_lock_persistence; pub mod contact; @@ -58,6 +59,7 @@ pub mod platform_address_sync; pub mod platform_address_types; pub mod platform_addresses; pub mod platform_wallet_info; +pub mod provider_key_at_index; mod runtime; #[cfg(feature = "shielded")] pub mod shielded_persistence; @@ -78,6 +80,7 @@ pub mod wallet_restore_types; pub mod xpub_render; // Re-exports +pub use address_private_key::*; pub use asset_lock::*; pub use asset_lock_persistence::*; pub use contact::*; @@ -126,6 +129,7 @@ pub use platform_address_sync::*; pub use platform_address_types::*; pub use platform_addresses::*; pub use platform_wallet_info::*; +pub use provider_key_at_index::*; #[cfg(feature = "shielded")] pub use shielded_send::*; #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 4d614581c2f..3f6bd127e4c 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -7,9 +7,11 @@ use bincode::config; use key_wallet::account::account_collection::AccountCollection; -use key_wallet::account::{Account, AccountType, StandardAccountType}; +use key_wallet::account::{Account, AccountType, BLSAccount, EdDSAAccount, StandardAccountType}; use key_wallet::bip32::DerivationPath; use key_wallet::bip32::ExtendedPubKey; +use key_wallet::derivation_bls_bip32::ExtendedBLSPubKey; +use key_wallet::derivation_slip10::ExtendedEd25519PubKey; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, PublicKeyType}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; @@ -22,6 +24,7 @@ use crate::types::{FFINetwork, Network}; use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState, Merge, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, }; use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet::wallet::{PerAccountPlatformAddressState, PerWalletPlatformAddressState}; @@ -48,8 +51,8 @@ use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI, - StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, - WalletRestoreEntryFFI, + ProviderPlatformNodeKeyFFI, StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI, + UtxoRestoreEntryFFI, WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -676,11 +679,15 @@ impl PlatformWalletPersistence for FFIPersister { // window — `AccountSpecFFI.account_xpub_bytes` borrows into // it. Same lifetime discipline the prior dedicated callback // used. - if !changeset.account_registrations.is_empty() { + if !changeset.account_registrations.is_empty() + || !changeset.provider_key_account_registrations.is_empty() + { if let Some(cb) = self.callbacks.on_persist_account_registrations_fn { - let entries = &changeset.account_registrations; - match build_account_specs_for_callback(entries) { - Ok((specs, _xpub_bytes_storage)) => { + match build_account_specs_for_callback( + &changeset.account_registrations, + &changeset.provider_key_account_registrations, + ) { + Ok((specs, _xpub_bytes_storage, _derived_keys_storage)) => { let result = unsafe { cb( self.callbacks.context, @@ -689,11 +696,12 @@ impl PlatformWalletPersistence for FFIPersister { specs.len(), ) }; - // Force the spec / byte buffers to live - // until after the callback even though - // their drop happens on scope exit anyway. + // Force the spec / byte buffers / derived-key + // buffers to live until after the callback even + // though their drop happens on scope exit anyway. drop(specs); drop(_xpub_bytes_storage); + drop(_derived_keys_storage); if result != 0 { eprintln!( "Account registrations persistence callback returned error code {}", @@ -2290,6 +2298,10 @@ fn build_account_spec_ffi(account_type: &AccountType, xpub_bytes: &[u8]) -> Acco friend_identity_id: [0u8; 32], account_xpub_bytes: xpub_bytes.as_ptr(), account_xpub_bytes_len: xpub_bytes.len(), + // Set by `build_account_specs_for_callback` for the + // `ProviderPlatformKeys` entry; null/0 for every other account. + derived_platform_node_keys: std::ptr::null(), + derived_platform_node_keys_count: 0, }; // The producer side casts each `AccountTypeTagFFI` / // `StandardAccountTypeTagFFI` variant to `u8` because both fields @@ -2383,28 +2395,94 @@ fn build_account_spec_ffi(account_type: &AccountType, xpub_bytes: &[u8]) -> Acco } /// Build the `Vec` array for -/// `on_persist_account_registrations_fn` plus the parallel -/// `Vec>` of bincoded xpub byte buffers each spec borrows -/// from. The two Vecs share lifetime — caller drops both after the -/// callback returns. +/// `on_persist_account_registrations_fn` plus the parallel storage each +/// spec borrows into: +/// 1. `Vec>` — bincoded xpub byte buffers +/// (`account_xpub_bytes`). +/// 2. `Vec>` — one inner Vec per +/// provider entry holding its pre-derived platform-node keys +/// (`derived_platform_node_keys`); empty for the BLS operator entry +/// and for every ECDSA account. +/// +/// All three share lifetime — the caller must keep them alive until +/// after the callback returns. +#[allow(clippy::type_complexity)] fn build_account_specs_for_callback( entries: &[AccountRegistrationEntry], -) -> Result<(Vec, Vec>), String> { - // Pre-encode every xpub once so the spec slot can borrow the - // pointer + length without a self-referential lifetime trick. - let xpub_buffers: Vec> = entries + provider_entries: &[ProviderKeyAccountEntry], +) -> Result< + ( + Vec, + Vec>, + Vec>, + ), + String, +> { + // Pre-encode every extended public key once so each spec slot can + // borrow the pointer + length without a self-referential lifetime + // trick. ECDSA accounts encode their secp256k1 `ExtendedPubKey`; + // provider key accounts (BLS operator / EdDSA platform node) + // encode their own-curve extended public key into the same slot — + // the `type_tag` disambiguates the decode on the restore side. + let mut xpub_buffers: Vec> = Vec::with_capacity(entries.len() + provider_entries.len()); + for entry in entries { + let bytes = bincode::encode_to_vec(entry.account_xpub, config::standard()) + .map_err(|e| format!("failed to encode account xpub: {}", e))?; + xpub_buffers.push(bytes); + } + for entry in provider_entries { + let bytes = match &entry.extended_public_key { + ProviderKeyExtendedPubKey::Bls(key) => bincode::encode_to_vec(key, config::standard()) + .map_err(|e| format!("failed to encode provider BLS xpub: {}", e))?, + ProviderKeyExtendedPubKey::EdDSA(key) => { + bincode::encode_to_vec(key, config::standard()) + .map_err(|e| format!("failed to encode provider EdDSA xpub: {}", e))? + } + }; + xpub_buffers.push(bytes); + } + + // Pre-derived platform-node key storage, index-aligned to + // `provider_entries`. Built to completion BEFORE any spec borrows a + // pointer into it so the inner Vecs never move under a live pointer. + let derived_storage: Vec> = provider_entries .iter() .map(|entry| { - bincode::encode_to_vec(entry.account_xpub, config::standard()) - .map_err(|e| format!("failed to encode account xpub: {}", e)) + entry + .derived_platform_node_keys + .iter() + .map(|k| ProviderPlatformNodeKeyFFI { + index: k.index, + public_key: k.public_key, + node_id: k.node_id, + }) + .collect() }) - .collect::>()?; - let specs: Vec = entries - .iter() - .zip(xpub_buffers.iter()) - .map(|(entry, bytes)| build_account_spec_ffi(&entry.account_type, bytes)) .collect(); - Ok((specs, xpub_buffers)) + + let mut specs: Vec = Vec::with_capacity(xpub_buffers.len()); + let mut idx = 0; + for entry in entries { + specs.push(build_account_spec_ffi( + &entry.account_type, + &xpub_buffers[idx], + )); + idx += 1; + } + for (p_idx, entry) in provider_entries.iter().enumerate() { + let mut spec = build_account_spec_ffi(&entry.account_type, &xpub_buffers[idx]); + // Point at the pre-built (stable) derived-key storage for this + // provider entry. Empty for the BLS operator account, so its + // spec keeps the null/0 default from `build_account_spec_ffi`. + let rows = &derived_storage[p_idx]; + if !rows.is_empty() { + spec.derived_platform_node_keys = rows.as_ptr(); + spec.derived_platform_node_keys_count = rows.len(); + } + specs.push(spec); + idx += 1; + } + Ok((specs, xpub_buffers, derived_storage)) } /// Build the `Vec` array for @@ -2877,6 +2955,68 @@ fn build_wallet_start_state( }; let xpub_bytes = unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; + + // Provider key-material accounts (BLS operator keys / EdDSA + // platform node keys) live in dedicated `Option` fields on the + // collection and carry a non-secp256k1 extended public key in + // the same `account_xpub_bytes` slot. Rebuild them watch-only + // via the type-specific `new` + insert methods rather than the + // ECDSA `Account::from_xpub` / `insert` path (which would fail + // to decode the bytes and reject the provider `AccountType`). + match account_type { + AccountType::ProviderOperatorKeys => { + let (bls_pubkey, _): (ExtendedBLSPubKey, usize) = + bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { + PersistenceError::backend(format!( + "failed to decode provider BLS xpub: {}", + e + )) + })?; + let bls_account = BLSAccount::new( + Some(entry.wallet_id.to_vec()), + account_type, + bls_pubkey, + network, + ) + .map_err(|e| { + PersistenceError::backend(format!("BLSAccount::new failed: {:?}", e)) + })?; + accounts.insert_bls_account(bls_account).map_err(|e| { + PersistenceError::backend(format!( + "AccountCollection::insert_bls_account failed: {}", + e + )) + })?; + continue; + } + AccountType::ProviderPlatformKeys => { + let (ed_pubkey, _): (ExtendedEd25519PubKey, usize) = + bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { + PersistenceError::backend(format!( + "failed to decode provider EdDSA xpub: {}", + e + )) + })?; + let eddsa_account = EdDSAAccount::new( + Some(entry.wallet_id.to_vec()), + account_type, + ed_pubkey, + network, + ) + .map_err(|e| { + PersistenceError::backend(format!("EdDSAAccount::new failed: {:?}", e)) + })?; + accounts.insert_eddsa_account(eddsa_account).map_err(|e| { + PersistenceError::backend(format!( + "AccountCollection::insert_eddsa_account failed: {}", + e + )) + })?; + continue; + } + _ => {} + } + let (account_xpub, _): (ExtendedPubKey, usize) = bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { PersistenceError::backend(format!("failed to decode account xpub: {}", e)) @@ -3137,6 +3277,9 @@ fn build_wallet_start_state( friend_identity_id: u.friend_identity_id, account_xpub_bytes: std::ptr::null(), account_xpub_bytes_len: 0, + // Irrelevant to `account_type_from_spec` routing. + derived_platform_node_keys: std::ptr::null(), + derived_platform_node_keys_count: 0, }; // Skip-and-continue is correct ONLY for the legacy // `IdentityAuthentication{Ecdsa,Bls}` tag bytes (15 / 16) diff --git a/packages/rs-platform-wallet-ffi/src/provider_key_at_index.rs b/packages/rs-platform-wallet-ffi/src/provider_key_at_index.rs new file mode 100644 index 00000000000..0a950d3e369 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/provider_key_at_index.rs @@ -0,0 +1,294 @@ +//! FFI binding for deriving a wallet's provider key-material key at a +//! single index — [`platform_wallet_provider_key_at_index`]. +//! +//! Thin marshalling over +//! [`PlatformWallet::derive_provider_key_at_index`](platform_wallet::PlatformWallet::derive_provider_key_at_index): +//! the whole derivation decision (which curve, hardened vs non-hardened, +//! whether a seed is even needed) lives on the Rust library side. This +//! shim only resolves the mnemonic (for the external-signable / +//! watch-only wallets the iOS app holds, whose seed lives in the iOS +//! Keychain rather than the `WalletManager`) and marshals the resulting +//! hex strings across the C ABI. +//! +//! # Key source: chosen by wallet capability +//! +//! Mirrors the sibling +//! [`platform_wallet_address_private_key`](crate::platform_wallet_address_private_key): +//! a [`MnemonicResolverHandle`] is a *capability*, consulted only when +//! the in-process wallet lacks resident private keys **and** a seed is +//! actually required for the request. +//! +//! The curve asymmetry decides when the resolver is required: +//! - **Operator (BLS), public listing:** derives from the account xpub +//! with no seed — the resolver is never touched, even for a watch-only +//! wallet. `include_private` flips this to require a seed. +//! - **Platform node (Ed25519):** SLIP-10 is hardened-only, so *every* +//! derivation (public or private) needs the seed. A watch-only wallet +//! therefore requires the resolver even to list platform-node public +//! keys; a null resolver handle for such a wallet errors. +//! +//! The two-phase locking rule from the address-key sibling applies here +//! too: the wallet-manager read guard is NEVER held across the Swift +//! resolver callback. + +use std::ffi::CString; +use std::os::raw::c_char; + +use key_wallet::bip32::ExtendedPrivKey; +use platform_wallet::{ProviderDerivedKey, ProviderKeyKind}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::error::*; +use crate::handle::*; +use crate::identity_keys_from_mnemonic::resolve_master_from_resolver; +use crate::types::Network; +use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use rs_sdk_ffi::MnemonicResolverHandle; + +/// Which provider key-material account to derive from. Matches the +/// account `type_tag`s the host already uses (10 = operator, 11 = +/// platform node) so the Swift side can pass the same discriminator it +/// renders with. +pub const PROVIDER_KEY_KIND_OPERATOR: u8 = 10; +/// See [`PROVIDER_KEY_KIND_OPERATOR`]. +pub const PROVIDER_KEY_KIND_PLATFORM_NODE: u8 = 11; + +/// One provider key derived at a single index, in the hex forms the host +/// renders. +/// +/// All strings are heap-allocated and owned by Rust until released by +/// [`platform_wallet_provider_key_at_index_free`], which zeroizes the +/// private-key backing bytes before freeing. +#[repr(C)] +pub struct ProviderKeyAtIndexFFI { + /// The key index that was derived (`#0..`). + pub index: u32, + /// Null-terminated lowercase hex of the raw curve public key — 96 + /// hex chars for a BLS-48 operator key, 64 for an Ed25519-32 + /// platform-node key. Null on the pre-cleared / freed state. + pub public_key_hex: *mut c_char, + /// Null-terminated lowercase hex of the 20-byte platform node id + /// (40 chars) — `hash160` of the Ed25519 public key. Null for + /// operator keys (no node id) and on the empty state. + pub node_id_hex: *mut c_char, + /// Null-terminated lowercase hex of the raw 32-byte private scalar + /// (64 chars), populated only when `include_private` was set. BLS / + /// Ed25519 keys have no WIF, so this is the only private form. Null + /// otherwise; zeroized by the free function. + pub private_key_hex: *mut c_char, +} + +impl ProviderKeyAtIndexFFI { + /// All-null row so a failed call leaves the caller looking at known + /// empty state instead of uninitialized memory. + fn empty() -> Self { + Self { + index: 0, + public_key_hex: std::ptr::null_mut(), + node_id_hex: std::ptr::null_mut(), + private_key_hex: std::ptr::null_mut(), + } + } +} + +/// Derive this wallet's provider key of `kind` at `index` and return it +/// as hex (public key, optional node id, optional private key). +/// +/// # Parameters +/// - `wallet_handle` — platform-wallet handle. +/// - `mnemonic_resolver_handle` — Swift-owned [`MnemonicResolverHandle`], +/// consulted only when a seed is required and the in-process wallet +/// lacks resident private keys (see the module docs). May be null for +/// an operator public listing on any wallet, or for a resident-key +/// wallet. +/// - `kind` — [`PROVIDER_KEY_KIND_OPERATOR`] (BLS, tag 10) or +/// [`PROVIDER_KEY_KIND_PLATFORM_NODE`] (Ed25519, tag 11). +/// - `index` — the key index to derive. +/// - `include_private` — also return the raw private scalar. +/// - `out` — populated on success. Release with +/// [`platform_wallet_provider_key_at_index_free`]. Left at the empty +/// zero state on error. +/// +/// Returns a [`PlatformWalletFFIResult`]; `NotFound` for an unknown +/// handle, and the typed wallet-error code (with a descriptive message) +/// when the wallet has no account of that kind or derivation fails. +/// +/// # Safety +/// `wallet_handle` must come from the platform-wallet handle registry. +/// `mnemonic_resolver_handle`, when non-null, must come from +/// [`rs_sdk_ffi::dash_sdk_mnemonic_resolver_create`] and remain valid +/// for the duration of the call. `out` must be a valid writable pointer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_provider_key_at_index( + wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + kind: u8, + index: u32, + include_private: bool, + out: *mut ProviderKeyAtIndexFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out); + unsafe { *out = ProviderKeyAtIndexFFI::empty() }; + + let kind = match kind { + PROVIDER_KEY_KIND_OPERATOR => ProviderKeyKind::Operator, + PROVIDER_KEY_KIND_PLATFORM_NODE => ProviderKeyKind::PlatformNode, + other => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "unknown provider key kind {other} (expected {PROVIDER_KEY_KIND_OPERATOR} \ + operator or {PROVIDER_KEY_KIND_PLATFORM_NODE} platform node)" + ), + ); + } + }; + + // A seed is needed for every private reveal, and for *all* + // platform-node derivations (Ed25519/SLIP-10 is hardened-only, so + // even the public key needs the private path). An operator public + // listing needs nothing but the account xpub. + let need_seed = include_private || matches!(kind, ProviderKeyKind::PlatformNode); + + let option = PLATFORM_WALLET_STORAGE.with_item( + wallet_handle, + |wallet| -> Result { + let network: Network = wallet.network(); + + // Phase 1 — capability probe under a SHORT read guard, + // dropped before any resolver interaction. Only relevant when + // a seed is required at all. + let is_resident = if need_seed { + let wm = wallet.wallet_manager().blocking_read(); + match wm.get_wallet(&wallet.wallet_id()) { + Some(key_wallet) => { + !key_wallet.is_external_signable() && !key_wallet.is_watch_only() + } + None => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "wallet not found in wallet manager", + )); + } + } + } else { + // No seed needed — the resident/watch-only distinction is + // irrelevant; the library derives from the account xpub. + true + }; + + // Phase 2 — resolve the master xpriv for external-signable / + // watch-only wallets that need a seed. NEVER under the guard + // above: the resolver synchronously re-enters Swift and reads + // the iOS Keychain (which can stall on biometric unlock). + let mut master_opt: Option = None; + if need_seed && !is_resident { + if mnemonic_resolver_handle.is_null() { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "this wallet has no resident private keys (external-signable / \ + watch-only); a mnemonic resolver handle is required to derive \ + this provider key", + )); + } + let wallet_id = wallet.wallet_id(); + // SAFETY: handle is non-null (checked) and the caller's + // safety contract guarantees it came from + // `dash_sdk_mnemonic_resolver_create`. + master_opt = Some(unsafe { + resolve_master_from_resolver(mnemonic_resolver_handle, &wallet_id, network)? + }); + } + + // Phase 3 — library derive (re-acquires the guard internally; + // the resolver, if any, has already run). + let result = wallet.derive_provider_key_at_index( + kind, + index, + master_opt.as_ref(), + include_private, + ); + + // Wipe the resolved master's inner scalar — `ExtendedPrivKey` + // has no `Drop` / `Zeroize`. No-op on the seedless path. + if let Some(mut master) = master_opt { + master.private_key.non_secure_erase(); + } + + result.map_err(PlatformWalletFFIResult::from) + }, + ); + let result = unwrap_option_or_return!(option); + let derived = unwrap_result_or_return!(result); + + // Move the sensitive field out. `private_key` (Zeroizing) is wiped + // when it drops at the end of this function; its hex byte buffer + // moves directly into the returned CString (no residual plaintext + // copy left on the heap) and is zeroized by `_free`. + let ProviderDerivedKey { + index, + public_key_bytes, + node_id, + private_key, + } = derived; + + let public_key_hex = unwrap_result_or_return!(CString::new(hex::encode(public_key_bytes))); + let node_id_hex = match node_id { + Some(id) => unwrap_result_or_return!(CString::new(hex::encode(id))).into_raw(), + None => std::ptr::null_mut(), + }; + // Secret: marshal through `secret_string_into_raw` so no un-zeroized + // plaintext copy is stranded during CString NUL-termination (see its + // docs). `public_key_hex` / `node_id_hex` above are public material, + // so a plain `CString::new` is fine for them. + let private_key_hex = match private_key { + Some(pk) => unwrap_result_or_return!(crate::address_private_key::secret_string_into_raw( + Zeroizing::new(hex::encode(&pk[..])) + )), + None => std::ptr::null_mut(), + }; + + unsafe { + *out = ProviderKeyAtIndexFFI { + index, + public_key_hex: public_key_hex.into_raw(), + node_id_hex, + private_key_hex, + }; + } + PlatformWalletFFIResult::ok() +} + +/// Release a [`ProviderKeyAtIndexFFI`] populated by +/// [`platform_wallet_provider_key_at_index`], zeroizing the sensitive +/// private-key backing bytes before freeing. Safe on a null outer +/// pointer or an already-freed / empty struct (no-op). Fields are nulled +/// after free so a second call is idempotent. +/// +/// # Safety +/// `out`'s pointers must have been produced by +/// [`platform_wallet_provider_key_at_index`] and must not be freed twice. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_provider_key_at_index_free( + out: *mut ProviderKeyAtIndexFFI, +) { + if out.is_null() { + return; + } + let out = unsafe { &mut *out }; + // Public key + node id are not secret — free without scrubbing. + if !out.public_key_hex.is_null() { + let _ = unsafe { CString::from_raw(out.public_key_hex) }; + out.public_key_hex = std::ptr::null_mut(); + } + if !out.node_id_hex.is_null() { + let _ = unsafe { CString::from_raw(out.node_id_hex) }; + out.node_id_hex = std::ptr::null_mut(); + } + // The private-key hex is sensitive — zeroize its bytes before free. + if !out.private_key_hex.is_null() { + let mut bytes = unsafe { CString::from_raw(out.private_key_hex) }.into_bytes_with_nul(); + bytes.zeroize(); + out.private_key_hex = std::ptr::null_mut(); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/wallet_registration_persistence.rs b/packages/rs-platform-wallet-ffi/src/wallet_registration_persistence.rs index cd6c488d91c..527be165e6e 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_registration_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_registration_persistence.rs @@ -67,19 +67,22 @@ unsafe impl Sync for AccountAddressPoolFFI {} // Expected layout on 64-bit targets (all fields in declaration // order under `#[repr(C)]`): // -// 0..=99 account AccountSpecFFI (96 bytes +// 0..=111 account AccountSpecFFI (108 bytes // + 4 bytes inline pad to align -// to the trailing 8-byte -// pointer below — see the -// layout note on AccountSpecFFI) +// the trailing 8-byte pointers +// — includes the appended +// derived-platform-node-keys +// ptr/len pair; see the layout +// note on AccountSpecFFI) // ... // // The exact internal padding inside `AccountSpecFFI` is fixed by the // upstream layout guard in `wallet_restore_types`; we only pin the // outer struct size here. On 64-bit targets the trailing pool fields -// add `1 + 7 (pad) + 8 (ptr) + 8 (len) = 24` bytes after a 96-byte -// account, for a total of 120. +// add `1 + 7 (pad) + 8 (ptr) + 8 (len) = 24` bytes after a 112-byte +// account (96 + the derived-platform-node-keys ptr/len pair), for a +// total of 136. // // Recompute via `std::mem::size_of` if the spec layout changes. -const _: [u8; 120] = [0u8; std::mem::size_of::()]; +const _: [u8; 136] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 95629084629..c33b72fb452 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -117,6 +117,30 @@ impl StandardAccountTypeTagFFI { } } +/// One pre-derived platform-node (Ed25519) key carried on +/// [`AccountSpecFFI::derived_platform_node_keys`] for the +/// `ProviderPlatformKeys` account (`type_tag == 11`). +/// +/// Ed25519/SLIP-10 is hardened-only, so the wallet can never extend +/// its platform-node pool without the seed — the batch is pre-derived +/// at registration (while the seed is in hand) and surfaced here so +/// the host can persist + display it with no keychain prompt. Plain +/// POD (no pointers): the `hash160` node id is precomputed on the Rust +/// side so the host needs no RIPEMD-160 of its own. The private scalar +/// is never carried — a per-index reveal still routes through +/// `platform_wallet_provider_key_at_index` with the resolver. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ProviderPlatformNodeKeyFFI { + /// Hardened key index within the platform-node pool (`#0..`). + pub index: u32, + /// Raw 32-byte Ed25519 public key at this index. + pub public_key: [u8; 32], + /// 20-byte platform node id — `hash160` of the Ed25519 public key + /// (the ProRegTx `platform_node_id`). + pub node_id: [u8; 20], +} + /// Flat account spec carried in `WalletRestoreEntryFFI.accounts`. /// /// Field relevance per `type_tag`: @@ -130,8 +154,12 @@ impl StandardAccountTypeTagFFI { /// * `AssetLockShieldedAddressTopUp` — (none) /// * `ProviderVotingKeys` — (none) /// * `ProviderOwnerKeys` — (none) -/// * `ProviderOperatorKeys` — (none) -/// * `ProviderPlatformKeys` — (none) +/// * `ProviderOperatorKeys` — (none); `account_xpub_bytes` +/// carries a bincode-encoded extended **BLS** public key, not a +/// secp256k1 `ExtendedPubKey` +/// * `ProviderPlatformKeys` — (none); `account_xpub_bytes` +/// carries a bincode-encoded extended **Ed25519** public key, not a +/// secp256k1 `ExtendedPubKey` /// * `DashpayReceivingFunds` — `index`, `user_identity_id`, `friend_identity_id` /// * `DashpayExternalAccount` — `index`, `user_identity_id`, `friend_identity_id` /// * `PlatformPayment` — `index` (as `account`), `key_class` @@ -152,10 +180,29 @@ pub struct AccountSpecFFI { pub key_class: u32, pub user_identity_id: [u8; 32], pub friend_identity_id: [u8; 32], - /// Bincode-encoded [`key_wallet::bip32::ExtendedPubKey`]. Valid for + /// Bincode-encoded [`key_wallet::bip32::ExtendedPubKey`] for ECDSA + /// accounts. For the two provider key-material accounts the bytes + /// are instead a bincode-encoded extended BLS + /// (`ProviderOperatorKeys`) or Ed25519 (`ProviderPlatformKeys`) + /// public key — the `type_tag` selects the decode. Valid for /// callback duration only; Swift owns the allocation. pub account_xpub_bytes: *const u8, pub account_xpub_bytes_len: usize, + /// Pre-derived platform-node (Ed25519) public keys — only populated + /// on the **write** callback for the `ProviderPlatformKeys` account + /// (`type_tag == 11`); `null` / `0` for every other account type. + /// + /// On the write callback (`on_persist_account_registrations_fn`) + /// this is Rust-owned and valid for the callback window only — the + /// host copies the rows into its account row so the Node Keys + /// screen can list them from persistence without re-deriving. On + /// the **load** callback the host leaves this `null` / `0`: the + /// Rust load path does not consume it (it is display data the host + /// is the sole source of truth for), and the persisted account row + /// is never rewritten after registration, so the batch survives the + /// SwiftData → restore → re-persist cycle untouched. + pub derived_platform_node_keys: *const ProviderPlatformNodeKeyFFI, + pub derived_platform_node_keys_count: usize, } /// Per-identity public-key row carried on diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 8dc2c705427..d9ac73fc754 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -961,6 +961,87 @@ pub struct AccountRegistrationEntry { pub account_xpub: ExtendedPubKey, } +/// Non-secp256k1 extended public key carried by a +/// [`ProviderKeyAccountEntry`]. +/// +/// The BLS operator-key account and the EdDSA platform-node-key account +/// each hold an extended public key over their own curve, not a +/// secp256k1 [`ExtendedPubKey`], so they can't ride the +/// [`AccountRegistrationEntry`] path. Variants are gated on the +/// `bls` / `eddsa` features that make the underlying account types +/// exist upstream; with both off the enum is uninhabited (no provider +/// key account can be produced). +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ProviderKeyExtendedPubKey { + /// Extended BLS public key of a `ProviderOperatorKeys` account. + #[cfg(feature = "bls")] + Bls(key_wallet::derivation_bls_bip32::ExtendedBLSPubKey), + /// Extended Ed25519 public key of a `ProviderPlatformKeys` account. + #[cfg(feature = "eddsa")] + EdDSA(key_wallet::derivation_slip10::ExtendedEd25519PubKey), +} + +/// One pre-derived platform-node (Ed25519) public key captured at +/// registration, in the forms the host displays without needing the +/// seed again. +/// +/// Ed25519/SLIP-10 is hardened-only — there is no public-key +/// derivation, so the wallet can never extend its platform-node pool +/// on demand the way the BLS operator pool does (non-hardened +/// `ckd_pub` off the account xpub). Pre-generating a fixed batch while +/// the seed is in hand at registration is therefore the only way to +/// list these keys later from an external-signable / watch-only +/// wallet without re-prompting for the mnemonic. Only the public parts +/// are carried — the private scalar stays resolver-gated per index. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ProviderPlatformNodePubKey { + /// Hardened key index within the platform-node pool (`#0..`). + pub index: u32, + /// Raw 32-byte Ed25519 public key at this index. + pub public_key: [u8; 32], + /// The 20-byte platform node id — `hash160` of the Ed25519 public + /// key, exactly what a ProRegTx `platform_node_id` field carries. + /// Precomputed on the Rust side so the host renders it without a + /// RIPEMD-160 implementation of its own. + pub node_id: [u8; 20], +} + +/// One entry per provider **key-material** account captured at +/// registration — the BLS operator-key account +/// ([`AccountType::ProviderOperatorKeys`]) and the EdDSA +/// platform-node-key account ([`AccountType::ProviderPlatformKeys`]). +/// +/// Upstream stores these in dedicated `Option` fields on the +/// `AccountCollection`, which `all_accounts()` deliberately excludes, +/// so they never enter the [`Self::account_xpub`](AccountRegistrationEntry) +/// snapshot the ECDSA accounts ride. Carried on +/// [`PlatformWalletChangeSet`] as +/// `Vec`; the FFI layer bincode-encodes the +/// [`extended_public_key`](Self::extended_public_key) into the same +/// `AccountSpecFFI.account_xpub_bytes` slot the ECDSA accounts use (the +/// `type_tag` disambiguates the decode) and the restore side rebuilds a +/// watch-only `BLSAccount` / `EdDSAAccount` from it. Append-only merge, +/// same as [`AccountRegistrationEntry`]. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ProviderKeyAccountEntry { + /// `ProviderOperatorKeys` (BLS) or `ProviderPlatformKeys` (EdDSA). + pub account_type: AccountType, + /// The account's extended public key. + pub extended_public_key: ProviderKeyExtendedPubKey, + /// Pre-derived platform-node (Ed25519) public keys, captured at + /// registration while the seed was in hand. Only populated for the + /// `ProviderPlatformKeys` (EdDSA) entry — always empty for the BLS + /// operator entry, whose pool the wallet can re-derive on demand + /// from the account xpub (non-hardened `ckd_pub`, no seed). The FFI + /// layer surfaces these to the host as a flat display array so the + /// Node Keys screen can list them from persistence with no keychain + /// prompt. See [`ProviderPlatformNodePubKey`]. + pub derived_platform_node_keys: Vec, +} + /// Address-pool snapshot for one `(account_type, pool_type)` pair. /// /// Routed through the changeset rather than a dedicated trait method @@ -1178,6 +1259,12 @@ pub struct PlatformWalletChangeSet { /// the merge policy (plain `Vec::extend`, dedup is the apply-side /// caller's job). pub account_registrations: Vec, + /// Provider key-material accounts (BLS operator keys / EdDSA + /// platform-node keys) emitted at registration. These live outside + /// the ECDSA `all_accounts()` set upstream, so they ride their own + /// vec rather than [`Self::account_registrations`]. See + /// [`ProviderKeyAccountEntry`] for the merge policy (append-only). + pub provider_key_account_registrations: Vec, /// Address-pool snapshots emitted at wallet create (initial /// gap-limit population) and on any pool extension / "used" flip. /// See [`AccountAddressPoolEntry`] for the merge policy. @@ -1291,6 +1378,8 @@ impl Merge for PlatformWalletChangeSet { // duplicate keys within one merged round are a no-op). self.account_registrations .extend(other.account_registrations); + self.provider_key_account_registrations + .extend(other.provider_key_account_registrations); self.account_address_pools .extend(other.account_address_pools); // Deferred contact-crypto queue: append-only add/clear deltas; the @@ -1320,6 +1409,7 @@ impl Merge for PlatformWalletChangeSet { .is_none_or(|m| m.is_empty()) && self.wallet_metadata.is_none() && self.account_registrations.is_empty() + && self.provider_key_account_registrations.is_empty() && self.account_address_pools.is_empty() && self.pending_contact_crypto_added.is_empty() && self.pending_contact_crypto_cleared.is_empty(); diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index cbd5f53a98b..edabe333175 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -31,6 +31,7 @@ pub use changeset::{ IdentityKeysChangeSet, KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey, PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, + ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, }; pub use client_start_state::ClientStartState; diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index fb1077a1ae7..f936b5aa68d 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -61,6 +61,7 @@ pub use wallet::core::WalletBalance; // DashPay types + crypto helpers re-exported through the identity // domain (they live under `identity::types::dashpay::*` and // `identity::crypto::*` internally). +pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, @@ -74,6 +75,7 @@ pub use wallet::identity::{ RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, }; pub use wallet::platform_wallet::PlatformWalletInfo; +pub use wallet::provider_key_at_index::{ProviderDerivedKey, ProviderKeyKind}; pub use wallet::PlatformAddressTag; pub use wallet::PlatformWallet; diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index e8a38200715..9f83330930f 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -8,9 +8,11 @@ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Network; +#[cfg(any(feature = "bls", feature = "eddsa"))] +use crate::changeset::ProviderKeyExtendedPubKey; use crate::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, PlatformWalletChangeSet, - PlatformWalletPersistence, WalletMetadataEntry, + PlatformWalletPersistence, ProviderKeyAccountEntry, WalletMetadataEntry, }; use crate::error::PlatformWalletError; use crate::wallet::core::WalletBalance; @@ -181,6 +183,64 @@ impl PlatformWalletManager

{ .iter() .map(|a| (a.account_type, a.account_xpub)) .collect(); + // Provider key-material accounts (BLS operator keys / EdDSA + // platform-node keys) live in dedicated `Option` fields on the + // `AccountCollection` that `all_accounts()` deliberately + // excludes, so snapshot them separately. They carry a + // non-secp256k1 extended public key; the persister bincode- + // encodes it and the restore path rebuilds them watch-only. + #[allow(unused_mut)] + let mut provider_key_account_registrations: Vec = Vec::new(); + #[cfg(feature = "bls")] + if let Some(bls) = wallet + .accounts + .bls_account_of_type(key_wallet::account::AccountType::ProviderOperatorKeys) + { + provider_key_account_registrations.push(ProviderKeyAccountEntry { + account_type: key_wallet::account::AccountType::ProviderOperatorKeys, + extended_public_key: ProviderKeyExtendedPubKey::Bls(bls.bls_public_key.clone()), + // The BLS operator pool extends on demand from the + // account xpub (non-hardened `ckd_pub`, no seed), so it + // needs no pre-derived batch. + derived_platform_node_keys: Vec::new(), + }); + } + #[cfg(feature = "eddsa")] + if let Some(eddsa) = wallet + .accounts + .eddsa_account_of_type(key_wallet::account::AccountType::ProviderPlatformKeys) + { + // Pre-derive a fixed batch of platform-node public keys while + // the wallet is still seed-bearing (`downgrade_to_external_signable` + // hasn't run yet). Ed25519/SLIP-10 is hardened-only, so this + // pool can never be extended later from the watch-only restore — + // capturing the public parts now lets the Node Keys screen list + // them from persistence with no keychain prompt. A derivation + // failure here is non-fatal: fall back to an empty batch (the UI + // then uses its resolver-based "Load Keys" path) rather than + // aborting the whole wallet registration. + let derived_platform_node_keys = + crate::wallet::provider_key_at_index::derive_platform_node_public_keys( + &wallet, + wallet.network, + crate::wallet::provider_key_at_index::PLATFORM_NODE_KEY_PREDERIVE_COUNT, + ) + .unwrap_or_else(|e| { + tracing::warn!( + error = %e, + "failed to pre-derive platform-node keys at registration; \ + the Node Keys screen will fall back to the resolver path" + ); + Vec::new() + }); + provider_key_account_registrations.push(ProviderKeyAccountEntry { + account_type: key_wallet::account::AccountType::ProviderPlatformKeys, + extended_public_key: ProviderKeyExtendedPubKey::EdDSA( + eddsa.ed25519_public_key.clone(), + ), + derived_platform_node_keys, + }); + } // Snapshot core (BIP44/CoinJoin/identity/provider/DashPay) // address pools. PlatformPayment accounts live in a separate // collection on `ManagedWalletInfo` and are handled below. @@ -301,6 +361,7 @@ impl PlatformWalletManager

{ account_xpub: *account_xpub, }) .collect(), + provider_key_account_registrations, ..Default::default() }; diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 6100e19d57c..1b81323ac74 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -107,6 +107,7 @@ impl PlatformWalletInfo { // a replay hook here. wallet_metadata: _, account_registrations: _, + provider_key_account_registrations: _, account_address_pools: _, // The deferred contact-crypto queue is persistence-only here too: // the in-memory queue is mutated directly at the enqueue (sweep) diff --git a/packages/rs-platform-wallet/src/wallet/core_address_key.rs b/packages/rs-platform-wallet/src/wallet/core_address_key.rs new file mode 100644 index 00000000000..f7cbc1e8299 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core_address_key.rs @@ -0,0 +1,162 @@ +//! On-demand private-key derivation for a single core (Layer-1) address. +//! +//! The wallet already knows the full BIP-32 derivation path of every +//! address it tracks (it is stored on the address's +//! [`AddressInfo`](key_wallet::managed_account::address_pool::AddressInfo) +//! inside the managed account collection). This module joins that path +//! back to key material so a caller can reveal the private key for one +//! address without knowing anything about derivation-path shapes — the +//! whole lookup-and-derive happens here, on the Rust side, in a single +//! call. +//! +//! # Key source +//! +//! The wallets the iOS app holds are external-signable (watch-only from +//! Rust's point of view): the BIP-39 seed lives in the iOS Keychain, not +//! in the `WalletManager`. For those, the caller resolves the mnemonic on +//! demand and hands us the master [`ExtendedPrivKey`] as `resolved_master`. +//! Wallets that *do* hold resident private keys (created from a raw seed) +//! pass `None` and we derive straight from the in-process key-wallet. +//! Either way the returned scalar is wrapped in [`Zeroizing`] so it is +//! scrubbed when dropped. + +use std::str::FromStr; + +use dashcore::secp256k1::Secp256k1; +use dashcore::{Address, PrivateKey as DashPrivateKey}; +use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; +use zeroize::Zeroizing; + +use super::platform_wallet::PlatformWallet; +use crate::error::PlatformWalletError; + +/// The private key for one of a wallet's core addresses, in the two +/// forms a caller wants to display: raw 32-byte scalar (sensitive, held +/// in a [`Zeroizing`] buffer) and its network-aware compressed WIF. +/// +/// The raw scalar backs the hex rendering the FFI produces; both it and +/// the WIF are wiped by the FFI free function once marshalled to the +/// host. +pub struct CoreAddressPrivateKey { + /// The address's full BIP-32 derivation path (informational — the + /// same value already stored on the address row). + pub derivation_path: DerivationPath, + /// Raw 32-byte secp256k1 private-key scalar. Zeroized on drop. + pub private_key: Zeroizing<[u8; 32]>, + /// Private key in WIF (Wallet Import Format) — network-aware + /// (mainnet vs testnet/devnet/regtest version byte) and compressed. + pub wif: String, +} + +impl PlatformWallet { + /// Derive the private key for `address_str`, one of this wallet's + /// tracked core addresses. + /// + /// Looks the address up across *every* managed account pool (standard + /// BIP44/BIP32, CoinJoin, identity registration/top-up funding, + /// provider keys, DashPay) to find its full derivation path, then + /// derives the secp256k1 private key at that path. + /// + /// `resolved_master` selects the key source (see the module docs): + /// `Some(master)` for external-signable / watch-only wallets whose + /// mnemonic the caller resolved on demand; `None` to derive from a + /// resident key-bearing wallet. + /// + /// # Errors + /// - [`PlatformWalletError::AddressOperation`] if `address_str` is not + /// a valid address for this wallet's network. + /// - [`PlatformWalletError::AddressNotFound`] if the address is valid + /// but not tracked by any of this wallet's accounts. + /// - [`PlatformWalletError::KeyDerivation`] if key derivation fails — + /// including passing `None` for a watch-only wallet that has no + /// resident private keys. + pub fn derive_core_address_private_key( + &self, + address_str: &str, + resolved_master: Option<&ExtendedPrivKey>, + ) -> Result { + let network = self.network(); + + // Parse + network-check the address against this wallet's network + // so a foreign-network string can't accidentally match a pool + // entry (base58 prefixes overlap across the test networks). + let address = Address::from_str(address_str) + .map_err(|e| { + PlatformWalletError::AddressOperation(format!( + "invalid address '{address_str}': {e}" + )) + })? + .require_network(network) + .map_err(|e| { + PlatformWalletError::AddressOperation(format!( + "address '{address_str}' is not valid for {network:?}: {e}" + )) + })?; + + // Single read-lock: the path lookup reads the managed account + // collection and the resident-key derive borrows the in-process + // wallet from the same guard. No Swift callback runs under this + // guard — the mnemonic resolver (if any) already ran on the FFI + // side and produced `resolved_master` before we were called. + let state = self.state_blocking(); + + let path = state + .core_wallet + .accounts + .all_accounts() + .into_iter() + .find_map(|account| account.get_address_info(&address).map(|info| info.path)) + .ok_or_else(|| { + PlatformWalletError::AddressNotFound(format!( + "address '{address_str}' is not tracked by any account in this wallet" + )) + })?; + + // Derive the raw scalar from the selected key source. + let secret_bytes: Zeroizing<[u8; 32]> = match resolved_master { + Some(master) => { + let secp = Secp256k1::new(); + let derived = master.derive_priv(&secp, &path).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to derive private key at {path}: {e}" + )) + })?; + Zeroizing::new(derived.private_key.secret_bytes()) + } + None => { + // Resident key-bearing wallet — derive from its own root. + // Errors here for a watch-only / external-signable wallet + // (no resident private key), which the caller must instead + // service with a `resolved_master`. + let secret_key = state.wallet().derive_private_key(&path).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to derive private key at {path} from resident wallet: {e}" + )) + })?; + Zeroizing::new(secret_key.secret_bytes()) + } + }; + + // Build the network-aware compressed WIF. `SecretKey::from_slice` + // over the just-derived bytes is infallible, but map its error + // rather than unwrap to keep the boundary panic-free. + let secret_key = dashcore::secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) + .map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "derived private key bytes were not a valid secp256k1 scalar: {e}" + )) + })?; + let wif = DashPrivateKey { + compressed: true, + network, + inner: secret_key, + } + .to_wif(); + + Ok(CoreAddressPrivateKey { + derivation_path: path, + private_key: secret_bytes, + wif, + }) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index edf707d8c83..1963422be7c 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -1,11 +1,13 @@ pub mod apply; pub mod asset_lock; pub mod core; +pub mod core_address_key; pub mod identity; pub mod persister; pub mod platform_addresses; pub mod platform_wallet; mod platform_wallet_traits; +pub mod provider_key_at_index; pub(crate) mod reservations; #[cfg(feature = "shielded")] pub mod shielded; @@ -13,6 +15,7 @@ pub mod tokens; pub use self::core::CoreWallet; pub use apply::ApplyError; +pub use core_address_key::CoreAddressPrivateKey; pub use identity::IdentityWallet; pub use platform_addresses::{ PerAccountPlatformAddressState, PerWalletPlatformAddressState, PlatformAddressTag, @@ -21,3 +24,4 @@ pub use platform_addresses::{ pub use platform_wallet::{ PlatformWallet, PlatformWalletInfo, WalletId, WalletStateReadGuard, WalletStateWriteGuard, }; +pub use provider_key_at_index::{ProviderDerivedKey, ProviderKeyKind}; diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs new file mode 100644 index 00000000000..0a04245df60 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -0,0 +1,556 @@ +//! On-demand per-index derivation of a wallet's provider key-material +//! keys — the BLS masternode **operator** keys +//! ([`AccountType::ProviderOperatorKeys`], FFI tag 10) and the Ed25519 +//! **platform-node** keys ([`AccountType::ProviderPlatformKeys`], FFI +//! tag 11). +//! +//! These two accounts don't hold on-chain addresses or a balance; each +//! holds an extended public key over its own curve from which the +//! wallet derives one key per index. This module joins that account +//! xpub (and, when a private key is wanted, the account seed) back to +//! per-index key material so a caller can list / reveal a provider key +//! without knowing anything about the DIP-3 derivation-path shapes — +//! the whole lookup-and-derive happens here, on the Rust side, in a +//! single call. Mirrors the sibling +//! [`derive_core_address_private_key`](PlatformWallet::derive_core_address_private_key). +//! +//! # The curve asymmetry (why the two kinds behave differently) +//! +//! The managed pool derives an operator key at +//! [`AddressPoolType::Absent`](key_wallet::managed_account::address_pool::AddressPoolType::Absent) +//! — a **non-hardened** child index — and a platform-node key at +//! [`AddressPoolType::AbsentHardened`](key_wallet::managed_account::address_pool::AddressPoolType::AbsentHardened) +//! — a **hardened** child index. That single fact drives everything: +//! +//! - **Operator (BLS/BIP32):** non-hardened derivation works from the +//! account *public* key (`ckd_pub`), so the 48-byte operator public +//! key at an index needs neither a seed nor the mnemonic resolver. +//! - **Platform node (Ed25519/SLIP-10):** SLIP-10 Ed25519 is +//! hardened-only — there is **no** public-key derivation. Even the +//! 32-byte public key at an index requires the account private key, +//! so listing platform-node keys always needs the seed (hence the +//! resolver for external-signable wallets). +//! +//! # Key source +//! +//! The account seed (the 32 secret bytes at the account's DIP-3 path, +//! the exact input [`Wallet::add_bls_account`] / `add_eddsa_account` +//! feed to `new_master`) is obtained two ways, matching +//! [`derive_core_address_private_key`](PlatformWallet::derive_core_address_private_key): +//! `Some(master)` for external-signable / watch-only wallets whose +//! mnemonic the caller resolved on demand; `None` to derive from a +//! resident key-bearing wallet. The seed and any returned scalar are +//! wrapped in [`Zeroizing`] so they are scrubbed when dropped. + +use dashcore::hashes::{hash160, Hash}; +use dashcore::secp256k1::Secp256k1; +use key_wallet::account::AccountType; +use key_wallet::bip32::{ChildNumber, ExtendedPrivKey}; +use key_wallet::derivation_bls_bip32::ExtendedBLSPrivKey; +use key_wallet::derivation_slip10::ExtendedEd25519PrivKey; +use zeroize::Zeroizing; + +use super::platform_wallet::PlatformWallet; +use crate::changeset::ProviderPlatformNodePubKey; +use crate::error::PlatformWalletError; + +/// Number of platform-node (Ed25519) keys pre-derived and persisted at +/// wallet registration. +/// +/// Ed25519/SLIP-10 is hardened-only, so the wallet can never extend +/// this pool later via the gap-limit the way funds pools do — there is +/// no public derivation to walk without the seed. Pre-generating a +/// fixed batch while the seed is in hand at registration is the only +/// option; 20 mirrors the standard address gap limit so the Node Keys +/// screen has a full first page to show from persistence alone. +pub const PLATFORM_NODE_KEY_PREDERIVE_COUNT: u32 = 20; + +/// Derive the first `count` platform-node (Ed25519) public keys from a +/// **seed-bearing** [`Wallet`](key_wallet::wallet::Wallet), returning +/// the 32-byte public key + 20-byte `hash160` node id per hardened +/// index. +/// +/// Used at registration (`PlatformWalletManager::register_wallet`) +/// to snapshot the pool while the seed is available, because the +/// platform-node curve is hardened-only and the pool can never be +/// extended later from an external-signable / watch-only wallet. The +/// derivation mirrors the private path in +/// [`PlatformWallet::derive_provider_key_at_index`] exactly: account +/// seed at the DIP-3 `ProviderPlatformKeys` path → +/// [`ExtendedEd25519PrivKey::new_master`] → hardened child `i` → public +/// key. Only the public parts leave this function — the account seed +/// is wrapped in [`Zeroizing`] and scrubbed on drop. +/// +/// # Errors +/// [`PlatformWalletError::KeyDerivation`] if the account path can't be +/// built, the wallet has no resident private key to derive the account +/// seed (i.e. it's already watch-only), or any per-index derivation +/// fails. +pub fn derive_platform_node_public_keys( + wallet: &key_wallet::wallet::Wallet, + network: key_wallet::Network, + count: u32, +) -> Result, PlatformWalletError> { + let account_type = AccountType::ProviderPlatformKeys; + + // Account-level seed: the same secp256k1 secret bytes at the DIP-3 + // `ProviderPlatformKeys` path that `Wallet::add_eddsa_account` feeds + // to `new_master`. Errors for a watch-only wallet with no resident + // private key — but at registration the wallet is still seed-bearing. + let account_path = account_type.derivation_path(network).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to build provider platform-node account path: {e}" + )) + })?; + let secret = wallet.derive_private_key(&account_path).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to derive provider platform-node account seed at {account_path}: {e}" + )) + })?; + let account_seed: Zeroizing<[u8; 32]> = Zeroizing::new(secret.secret_bytes()); + + let ed_master = + ExtendedEd25519PrivKey::new_master(network, account_seed.as_ref()).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to build Ed25519 master from platform-node seed: {e}" + )) + })?; + + let mut out = Vec::with_capacity(count as usize); + for index in 0..count { + // SLIP-10 Ed25519 is hardened-only — the pool derives + // platform-node keys at a single hardened index + // (`AddressPoolType::AbsentHardened`). + let child = ChildNumber::from_hardened_idx(index).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "invalid platform-node key index {index}: {e}" + )) + })?; + let derived = ed_master.derive_priv(&[child]).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to derive Ed25519 platform-node key at index {index}: {e}" + )) + })?; + let verifying = derived.public_key().map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to obtain Ed25519 public key at index {index}: {e}" + )) + })?; + // `to_bytes().to_vec()` is exactly how + // `derive_provider_key_at_index` materialises the 32-byte Ed25519 + // public key; normalise into a fixed array for the display struct. + let public_key_bytes = verifying.to_bytes().to_vec(); + let public_key: [u8; 32] = public_key_bytes.as_slice().try_into().map_err(|_| { + PlatformWalletError::KeyDerivation(format!( + "Ed25519 public key at index {index} was not 32 bytes" + )) + })?; + // The 20-byte platform node id = hash160(ed25519 pubkey), the + // value a ProRegTx `platform_node_id` matcher compares against. + let node_id: [u8; 20] = hash160::Hash::hash(&public_key_bytes).to_byte_array(); + out.push(ProviderPlatformNodePubKey { + index, + public_key, + node_id, + }); + } + Ok(out) +} + +/// Which provider key-material account to derive from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderKeyKind { + /// BLS masternode operator keys + /// ([`AccountType::ProviderOperatorKeys`], FFI tag 10). + Operator, + /// Ed25519 platform-node keys + /// ([`AccountType::ProviderPlatformKeys`], FFI tag 11). + PlatformNode, +} + +impl ProviderKeyKind { + /// The upstream account type this kind maps to. + fn account_type(self) -> AccountType { + match self { + ProviderKeyKind::Operator => AccountType::ProviderOperatorKeys, + ProviderKeyKind::PlatformNode => AccountType::ProviderPlatformKeys, + } + } +} + +/// One provider key derived at a single index, in the forms a caller +/// wants to display. +pub struct ProviderDerivedKey { + /// The key index within the provider pool (`#0..`). + pub index: u32, + /// Raw curve public key bytes: 48 for a BLS operator key (this is + /// exactly the bytes a ProRegTx `operator_public_key` field + /// carries), 32 for an Ed25519 platform-node key. + pub public_key_bytes: Vec, + /// The 20-byte platform node id — `hash160` of the Ed25519 public + /// key, the value a ProRegTx `platform_node_id` field carries and + /// the [`Payload::PubkeyHash`](dashcore::address::Payload) the pool + /// matcher compares against. `Some` for [`ProviderKeyKind::PlatformNode`]; + /// `None` for [`ProviderKeyKind::Operator`] (whose on-chain field is + /// the raw 48-byte BLS public key, not a hash). + pub node_id: Option<[u8; 20]>, + /// Raw private-key scalar (32 bytes), present only when the caller + /// asked for it. BLS / Ed25519 keys have no WIF, so this is the + /// only private form. Zeroized on drop. + pub private_key: Option>>, +} + +impl PlatformWallet { + /// Derive this wallet's provider key of `kind` at `index`. + /// + /// Public-only when `resolved_master` is `None` and + /// `include_private` is `false` — but note the curve asymmetry + /// documented at the module level: a [`ProviderKeyKind::Operator`] + /// public key derives straight from the account xpub with no seed, + /// whereas a [`ProviderKeyKind::PlatformNode`] key (Ed25519, SLIP-10 + /// hardened-only) always needs the account seed even for its public + /// key, so a watch-only wallet must supply `resolved_master` to list + /// platform-node keys at all. + /// + /// `resolved_master` selects the key source: `Some(master)` for + /// external-signable / watch-only wallets whose mnemonic the caller + /// resolved on demand; `None` to derive from a resident key-bearing + /// wallet. `include_private` additionally requests the raw private + /// scalar. + /// + /// # Errors + /// - [`PlatformWalletError::AddressNotFound`] if this wallet has no + /// account of the requested kind. + /// - [`PlatformWalletError::KeyDerivation`] if key derivation fails — + /// including passing `None` for a watch-only wallet that has no + /// resident private keys when a seed is required. + pub fn derive_provider_key_at_index( + &self, + kind: ProviderKeyKind, + index: u32, + resolved_master: Option<&ExtendedPrivKey>, + include_private: bool, + ) -> Result { + let network = self.network(); + let account_type = kind.account_type(); + + // Single read-lock: the account xpub read and the resident-key + // derive both borrow the in-process wallet from the same guard. + // No Swift callback runs under this guard — the mnemonic + // resolver (if any) already ran on the FFI side and produced + // `resolved_master` before we were called. + let state = self.state_blocking(); + + // The account-level seed (32 secret bytes at the account's DIP-3 + // path) is the input both curves' `new_master` consumes. Only + // compute it when a curve master is actually needed: an operator + // public listing derives straight from the account xpub, but an + // operator private reveal and *all* platform-node derivations + // (Ed25519/SLIP-10 is hardened-only) require it. + let need_seed = include_private || matches!(kind, ProviderKeyKind::PlatformNode); + + let account_seed: Option> = if need_seed { + let account_path = account_type.derivation_path(network).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to build provider account path for {account_type:?}: {e}" + )) + })?; + let seed = match resolved_master { + Some(master) => { + // Same seed `Wallet::add_bls_account` / + // `add_eddsa_account` derive: the account-level + // secp256k1 private-key bytes at the DIP-3 path. + let secp = Secp256k1::new(); + let derived = master.derive_priv(&secp, &account_path).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to derive provider account xpriv at {account_path}: {e}" + )) + })?; + Zeroizing::new(derived.private_key.secret_bytes()) + } + None => { + // Resident key-bearing wallet — derive the account + // seed from its own root. Errors here for a watch-only + // / external-signable wallet (no resident private + // key), which the caller must instead service with a + // `resolved_master`. + let secret = state + .wallet() + .derive_private_key(&account_path) + .map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to derive provider account key at {account_path} from \ + resident wallet: {e}" + )) + })?; + Zeroizing::new(secret.secret_bytes()) + } + }; + Some(seed) + } else { + None + }; + + match kind { + ProviderKeyKind::Operator => { + let account = state + .wallet() + .accounts + .bls_account_of_type(account_type) + .ok_or_else(|| { + PlatformWalletError::AddressNotFound( + "wallet has no BLS provider-operator-keys account".to_string(), + ) + })?; + + // Non-hardened index — the pool's `AddressPoolType::Absent`. + let child = ChildNumber::from_normal_idx(index).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "invalid operator key index {index}: {e}" + )) + })?; + + match account_seed { + // Private reveal: rebuild the BLS master from the + // account seed exactly as `Wallet::add_bls_account` + // does, then derive the child. + Some(seed) => { + let bls_master = ExtendedBLSPrivKey::new_master(network, seed.as_ref()) + .map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to build BLS master from operator seed: {e}" + )) + })?; + let derived = bls_master.derive_priv(child).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to derive BLS operator key at index {index}: {e}" + )) + })?; + let public_key_bytes = derived.public_key_bytes().to_vec(); + + // The private-path public key must equal the + // watch-only `ckd_pub` derivation the pool / + // ProRegTx matcher use — proves the seed and the + // BLS ckd are consistent. + debug_assert_eq!( + account + .bls_public_key + .derive_pub(child) + .map(|p| p.to_bytes()) + .ok(), + Some(derived.public_key_bytes()), + "BLS priv-derived operator pubkey diverged from ckd_pub" + ); + + let private_key = include_private + .then(|| Zeroizing::new(derived.private_key.to_be_bytes().to_vec())); + + Ok(ProviderDerivedKey { + index, + public_key_bytes, + node_id: None, + private_key, + }) + } + // Public-only: non-hardened `ckd_pub` off the account + // xpub — no seed / resolver needed for BLS. + None => { + let public_key_bytes = account + .bls_public_key + .derive_pub(child) + .map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to derive BLS operator public key at index \ + {index}: {e}" + )) + })? + .to_bytes() + .to_vec(); + Ok(ProviderDerivedKey { + index, + public_key_bytes, + node_id: None, + private_key: None, + }) + } + } + } + ProviderKeyKind::PlatformNode => { + // Existence check — a missing account is a caller error, + // not a derivation failure. (The stored xpub itself is + // only needed for the debug cross-check below.) + if state + .wallet() + .accounts + .eddsa_account_of_type(account_type) + .is_none() + { + return Err(PlatformWalletError::AddressNotFound( + "wallet has no Ed25519 provider-platform-keys account".to_string(), + )); + } + + // `need_seed` is always true for this kind. + let seed = account_seed.expect("platform-node derivation always seeds"); + + let ed_master = ExtendedEd25519PrivKey::new_master(network, seed.as_ref()) + .map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to build Ed25519 master from platform-node seed: {e}" + )) + })?; + + // In debug, confirm the seed reproduces the stored + // account xpub (proves the DIP-3 account path is right). + #[cfg(debug_assertions)] + { + use key_wallet::derivation_slip10::ExtendedEd25519PubKey; + if let (Ok(acct_pub), Some(account)) = ( + ExtendedEd25519PubKey::from_priv(&ed_master), + state.wallet().accounts.eddsa_account_of_type(account_type), + ) { + debug_assert_eq!( + acct_pub.public_key.to_bytes(), + account.ed25519_public_key.public_key.to_bytes(), + "Ed25519 account seed diverged from stored account xpub" + ); + } + } + + // SLIP-10 Ed25519 is hardened-only; the pool derives + // platform-node keys at a single hardened index + // (`AddressPoolType::AbsentHardened`). + let child = ChildNumber::from_hardened_idx(index).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "invalid platform-node key index {index}: {e}" + )) + })?; + let derived = ed_master.derive_priv(&[child]).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to derive Ed25519 platform-node key at index {index}: {e}" + )) + })?; + let verifying = derived.public_key().map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "failed to obtain Ed25519 public key at index {index}: {e}" + )) + })?; + let public_key_bytes = verifying.to_bytes().to_vec(); + + // The 20-byte platform node id = hash160(ed25519 pubkey), + // exactly what the ProRegTx `platform_node_id` matcher + // compares against (`Payload::PubkeyHash`). + let node_id: [u8; 20] = hash160::Hash::hash(&public_key_bytes).to_byte_array(); + + let private_key = + include_private.then(|| Zeroizing::new(derived.private_key.to_vec())); + + Ok(ProviderDerivedKey { + index, + public_key_bytes, + node_id: Some(node_id), + private_key, + }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::hashes::{hash160, Hash}; + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + use key_wallet::Network; + + // Canonical all-`abandon` BIP-39 test vector — deterministic, so the + // derived key material below is a stable golden vector. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + fn seed_bearing_wallet(network: Network) -> Wallet { + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) + .expect("wallet construction") + } + + /// The two load-bearing invariants for platform-node keys, pinned so an + /// upstream SLIP-10 / DIP-9 derivation regression can't silently hand + /// out wrong key material: `node_id == hash160(pubkey)` at every index, + /// and a stable golden pubkey/node-id for index 0 on testnet. + #[test] + fn platform_node_keys_are_consistent_and_pinned() { + let wallet = seed_bearing_wallet(Network::Testnet); + let keys = derive_platform_node_public_keys(&wallet, Network::Testnet, 20) + .expect("platform-node derivation"); + + assert_eq!(keys.len(), 20, "requested 20 keys"); + for (i, k) in keys.iter().enumerate() { + assert_eq!(k.index, i as u32, "index ordering"); + let expected_node_id: [u8; 20] = hash160::Hash::hash(&k.public_key).to_byte_array(); + assert_eq!( + k.node_id, expected_node_id, + "node_id must be hash160(ed25519 pubkey) at index {i}" + ); + } + + // All indices produce distinct keys (hardened SLIP-10 children). + let mut pubs: Vec<[u8; 32]> = keys.iter().map(|k| k.public_key).collect(); + pubs.sort(); + pubs.dedup(); + assert_eq!(pubs.len(), 20, "all 20 platform-node keys must be distinct"); + + // Golden vector — regenerating the same mnemonic must reproduce + // these exact bytes. A break here means the derivation path or an + // upstream crate changed. + assert_eq!( + hex::encode(keys[0].public_key), + "fb91ae39aba2a1b8f68016833bfcfcec8516d634237f5842a21b03c225e2b092", + "platform-node index-0 pubkey golden vector" + ); + assert_eq!( + hex::encode(keys[0].node_id), + "bb241cb734e78cfc8c537226322b1492d0458678", + "platform-node index-0 node-id golden vector" + ); + } + + /// Re-deriving the same (mnemonic, network) is byte-stable — the watch + /// -only restore path re-persists nothing, so display must be reproducible. + #[test] + fn platform_node_keys_are_stable_across_calls() { + let wallet = seed_bearing_wallet(Network::Mainnet); + let a = derive_platform_node_public_keys(&wallet, Network::Mainnet, 5).unwrap(); + let b = derive_platform_node_public_keys(&wallet, Network::Mainnet, 5).unwrap(); + assert_eq!( + a.iter().map(|k| k.public_key).collect::>(), + b.iter().map(|k| k.public_key).collect::>(), + "platform-node derivation must be deterministic" + ); + } + + /// Mainnet and testnet derive different platform-node key material from + /// the same mnemonic (network-scoped DIP-9 coin type). + #[test] + fn platform_node_keys_differ_across_networks() { + let mainnet = derive_platform_node_public_keys( + &seed_bearing_wallet(Network::Mainnet), + Network::Mainnet, + 1, + ) + .unwrap(); + let testnet = derive_platform_node_public_keys( + &seed_bearing_wallet(Network::Testnet), + Network::Testnet, + 1, + ) + .unwrap(); + assert_ne!( + mainnet[0].public_key, testnet[0].public_key, + "same mnemonic must yield different platform-node keys per network" + ); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift index 5e0fe5270f6..6833297f87f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift @@ -1,6 +1,31 @@ import Foundation import SwiftData +/// One pre-derived platform-node (Ed25519) public key persisted on a +/// `ProviderPlatformKeys` account (`accountType == 11`). +/// +/// Ed25519/SLIP-10 is hardened-only, so the wallet can never extend +/// its platform-node pool without the seed — the batch is derived once +/// at wallet registration (while the seed is available) and stored here +/// so the Node Keys detail screen lists it with no keychain prompt. The +/// 20-byte `nodeId` is `hash160(publicKey)`, precomputed on the Rust +/// side (the host needs no RIPEMD-160). The private scalar is never +/// stored — a per-index reveal re-derives it through the resolver. +public struct DerivedPlatformNodeKey: Codable, Equatable, Hashable, Sendable { + /// Hardened key index within the platform-node pool (`#0..`). + public var index: UInt32 + /// Raw 32-byte Ed25519 public key. + public var publicKey: Data + /// 20-byte platform node id (`hash160` of `publicKey`). + public var nodeId: Data + + public init(index: UInt32, publicKey: Data, nodeId: Data) { + self.index = index + self.publicKey = publicKey + self.nodeId = nodeId + } +} + /// SwiftData model for persisting a wallet account. /// /// Each account represents an HD derivation path (BIP44, CoinJoin, @@ -72,16 +97,37 @@ public final class PersistentAccount { /// `Dashpay*`.friend_identity_id (32 bytes). Empty `Data` for /// other variants. public var friendIdentityId: Data - /// Bincode-encoded `ExtendedPubKey` for this account. Populated by + /// Bincode-encoded extended public key for this account. For ECDSA + /// accounts it's an `ExtendedPubKey`; for the two provider + /// key-material accounts (`accountType == 10` operator = BLS, + /// `accountType == 11` platform node = Ed25519) it's the extended + /// BLS / Ed25519 public key instead. Populated by /// `on_persist_account_registrations_fn`, consumed by - /// `on_load_wallet_list_fn` to reconstruct a watch-only `Account` - /// via `Account::from_xpub`. `nil` means "not yet persisted" — + /// `on_load_wallet_list_fn` to reconstruct a watch-only account + /// (`Account::from_xpub` for ECDSA, `BLSAccount`/`EdDSAAccount` for + /// the provider accounts). `nil` means "not yet persisted" — /// account cannot be restored silently. Unique because two /// accounts can't legitimately share an xpub (would imply a key /// reuse / derivation collision); SQL UNIQUE allows multiple /// `nil` values, so freshly-inserted unhydrated rows don't /// conflict. @Attribute(.unique) public var accountExtendedPubKeyBytes: Data? + /// Pre-derived platform-node (Ed25519) public keys for the + /// `ProviderPlatformKeys` account (`accountType == 11`), captured + /// at wallet registration while the seed was available. Empty for + /// every other account type, and for wallets created before this + /// field existed (the Node Keys screen then falls back to the + /// resolver-based "Load Keys" path). Populated once by + /// `on_persist_account_registrations_fn` and read directly by the + /// UI — never rewritten on the restore / re-persist cycle, so the + /// batch is durable across relaunches with no keychain prompt. See + /// [`DerivedPlatformNodeKey`]. + /// + /// Declared with an inline default so SwiftData's lightweight + /// migration can add the column to stores created before this + /// field existed — without it, `ModelContainer` creation fatals + /// with `loadIssueModelContainer` on first launch after upgrade. + public var derivedPlatformNodeKeys: [DerivedPlatformNodeKey] = [] /// Record timestamps. public var createdAt: Date public var lastUpdated: Date @@ -139,6 +185,7 @@ public final class PersistentAccount { self.userIdentityId = Data() self.friendIdentityId = Data() self.accountExtendedPubKeyBytes = nil + self.derivedPlatformNodeKeys = [] self.createdAt = Date() self.lastUpdated = Date() self.coreAddresses = [] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index e4761e8d0bc..213f582e4fa 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -858,6 +858,183 @@ extension ManagedPlatformWallet { } } + /// The private key for one of this wallet's core (Layer-1) + /// addresses, in the two forms the developer UI renders it. + public struct CoreAddressPrivateKey: Sendable { + /// Lowercase hex of the raw 32-byte secp256k1 scalar (64 chars). + public let hex: String + /// Private key in WIF (Wallet Import Format) — network-aware, + /// compressed. Matches how other views in the example app + /// accept / display private keys. + public let wif: String + } + + /// Reveal the private key for one of this wallet's tracked core + /// addresses, returned as hex + WIF. + /// + /// Routes through the resolver-based FFI + /// `platform_wallet_address_private_key`. All of the + /// address-lookup + derivation-path work happens on the Rust side; + /// Swift only supplies the `MnemonicResolver` (so Rust can pull the + /// BIP-39 mnemonic on demand for the app's external-signable + /// wallets — the seed never round-trips into a Swift `String`) and + /// marshals the resulting strings back out. The same + /// capability-selected key-source contract as + /// `previewIdentityRegistrationKeys` applies: the resolver is + /// consulted only when the in-process wallet lacks resident keys. + /// + /// - Parameters: + /// - address: the core address string to reveal the key for. Must + /// be one of this wallet's tracked addresses. + /// - storage: defaults to a fresh `WalletStorage()` — overridable + /// for tests. Used by the resolver vtable to read the mnemonic. + /// - Throws: `PlatformWalletError` if the address is not tracked by + /// this wallet, the handle is invalid, or derivation fails. + public func coreAddressPrivateKey( + address: String, + storage: WalletStorage = WalletStorage() + ) throws -> CoreAddressPrivateKey { + // Same resolver lifetime rationale as + // `previewIdentityRegistrationKeys`: `withExtendedLifetime` + // pins the resolver across the whole synchronous FFI call so + // ARC can't deallocate its `passUnretained` ctx mid-call. + let resolver = MnemonicResolver(storage: storage) + + return try withExtendedLifetime(resolver) { + var out = AddressPrivateKeyFFI() + let result = address.withCString { addressPtr in + platform_wallet_address_private_key( + handle, + resolver.handle, + addressPtr, + &out + ) + } + // Free the Rust-owned (zeroizing) strings whether we + // succeeded or bailed — the free function no-ops on the + // zero struct. + defer { platform_wallet_address_private_key_free(&out) } + + try result.check() + + let hex = out.private_key_hex.map { String(cString: $0) } ?? "" + let wif = out.private_key_wif.map { String(cString: $0) } ?? "" + return CoreAddressPrivateKey(hex: hex, wif: wif) + } + } + + /// Which provider key-material account to derive from. Raw values + /// match the account `type_tag`s the host already renders with + /// (`PersistentAccount.accountType` 10 = operator, 11 = platform + /// node), so callers pass the same discriminator they display. + public enum ProviderKeyKind: UInt8, Sendable { + /// BLS masternode operator keys (`ProviderOperatorKeys`, tag 10). + case operatorBLS = 10 + /// Ed25519 platform-node keys (`ProviderPlatformKeys`, tag 11). + case platformNodeEdDSA = 11 + } + + /// One provider key derived at a single index, in the hex forms the + /// developer UI renders. + public struct ProviderDerivedKey: Sendable { + /// The key index that was derived (`#0..`). + public let index: UInt32 + /// Lowercase hex of the raw curve public key — 96 chars for a + /// BLS-48 operator key (the bytes a ProRegTx operator field + /// carries), 64 for an Ed25519-32 platform-node key. + public let publicKeyHex: String + /// Lowercase hex of the 20-byte platform node id (`hash160` of + /// the Ed25519 public key, the ProRegTx `platform_node_id`). + /// `nil` for operator keys, which have no node id. + public let nodeIdHex: String? + /// Lowercase hex of the raw 32-byte private scalar, present only + /// when the reveal requested it. BLS / Ed25519 keys have no WIF, + /// so this is the only private form. + public let privateKeyHex: String? + + /// Public memberwise init so hosts can build display rows from a + /// persisted platform-node batch (see + /// `PersistentAccount.derivedPlatformNodeKeys`) without a fresh + /// FFI derivation — the synthesized memberwise init is internal + /// and unreachable from the app module. + public init( + index: UInt32, + publicKeyHex: String, + nodeIdHex: String?, + privateKeyHex: String? + ) { + self.index = index + self.publicKeyHex = publicKeyHex + self.nodeIdHex = nodeIdHex + self.privateKeyHex = privateKeyHex + } + } + + /// Derive this wallet's provider key of `kind` at `index`, returned + /// as hex (public key, optional node id, optional private key). + /// + /// Routes through the resolver-based FFI + /// `platform_wallet_provider_key_at_index`. All of the derivation + /// (which curve, hardened vs non-hardened, whether a seed is even + /// needed) happens on the Rust side; Swift only supplies the + /// `MnemonicResolver` and marshals the resulting strings back out. + /// + /// The resolver is only *consulted* when Rust actually needs a seed: + /// an operator (BLS) public listing derives straight from the + /// account xpub and never fires the keychain read, whereas a + /// platform-node (Ed25519, SLIP-10 hardened-only) key needs the seed + /// even for its public key. Passing the resolver here is therefore + /// always safe — it stays dormant unless Rust calls it. + /// + /// - Parameters: + /// - kind: operator (BLS) or platform-node (Ed25519) keys. + /// - index: the key index to derive (`#0..`). + /// - includePrivate: also return the raw private scalar. + /// - storage: defaults to a fresh `WalletStorage()` — overridable + /// for tests. Used by the resolver vtable to read the mnemonic. + /// - Throws: `PlatformWalletError` if the wallet has no account of + /// that kind, the handle is invalid, or derivation fails. + public func providerKeyAtIndex( + kind: ProviderKeyKind, + index: UInt32, + includePrivate: Bool, + storage: WalletStorage = WalletStorage() + ) throws -> ProviderDerivedKey { + // Same resolver lifetime rationale as `coreAddressPrivateKey`: + // `withExtendedLifetime` pins the resolver across the whole + // synchronous FFI call so ARC can't deallocate its + // `passUnretained` ctx mid-call. + let resolver = MnemonicResolver(storage: storage) + + return try withExtendedLifetime(resolver) { + var out = ProviderKeyAtIndexFFI() + let result = platform_wallet_provider_key_at_index( + handle, + resolver.handle, + kind.rawValue, + index, + includePrivate, + &out + ) + // Free the Rust-owned strings (the private-key hex is + // zeroized inside) whether we succeeded or bailed — the free + // function no-ops on the zero struct. + defer { platform_wallet_provider_key_at_index_free(&out) } + + try result.check() + + let publicKeyHex = out.public_key_hex.map { String(cString: $0) } ?? "" + let nodeIdHex = out.node_id_hex.map { String(cString: $0) } + let privateKeyHex = out.private_key_hex.map { String(cString: $0) } + return ProviderDerivedKey( + index: out.index, + publicKeyHex: publicKeyHex, + nodeIdHex: nodeIdHex, + privateKeyHex: privateKeyHex + ) + } + } + /// Derive a single ECDSA identity-authentication keypair at an /// arbitrary `(identityIndex, keyId)` slot — the building block /// the "add key to existing identity" flow runs on. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index c7f67912379..e30fa12e5e6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3795,6 +3795,31 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { xpubBytes = Data() } + // Pre-derived platform-node (Ed25519) keys for the + // ProviderPlatformKeys account. Rust-owned + valid only for + // the callback window, so copy each row's bytes out now. + var derivedPlatformNodeKeys: [DerivedPlatformNodeKey] = [] + if let dkPtr = spec.derived_platform_node_keys, + spec.derived_platform_node_keys_count > 0 { + let rows = UnsafeBufferPointer( + start: dkPtr, + count: Int(spec.derived_platform_node_keys_count) + ) + for row in rows { + var pub = Data(count: 32) + withUnsafeBytes(of: row.public_key) { src in + pub.withUnsafeMutableBytes { dst in dst.copyMemory(from: src) } + } + var node = Data(count: 20) + withUnsafeBytes(of: row.node_id) { src in + node.withUnsafeMutableBytes { dst in dst.copyMemory(from: src) } + } + derivedPlatformNodeKeys.append( + DerivedPlatformNodeKey(index: row.index, publicKey: pub, nodeId: node) + ) + } + } + // Upsert keyed by the full account identity. We can't easily // express the identity tuple in a #Predicate with local `Data` // captures, so fetch by (walletId, accountType, accountIndex) @@ -3840,6 +3865,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { account.userIdentityId = userIdentityId account.friendIdentityId = friendIdentityId account.accountExtendedPubKeyBytes = xpubBytes + // Only overwrite the batch when this callback actually + // carries one (i.e. the registration-time ProviderPlatformKeys + // spec). Any other emitter passes an empty array, so a + // balance-only re-persist never wipes the registration batch — + // Swift is the sole source of truth for it (Rust never echoes + // it back on the load path). + if !derivedPlatformNodeKeys.isEmpty { + account.derivedPlatformNodeKeys = derivedPlatformNodeKeys + } account.lastUpdated = Date() if !self.inChangeset { try? backgroundContext.save() } } @@ -4023,6 +4057,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { copyBytes(acc.friendIdentityId, into: &spec.friend_identity_id) spec.account_xpub_bytes = UnsafePointer(xpubBuffer) spec.account_xpub_bytes_len = UInt(xpub.count) + // Display-only data the Rust load path ignores. The + // persisted account row keeps the batch on the Swift + // side (never rewritten after registration), so it is + // not round-tripped back through the restore entry. + spec.derived_platform_node_keys = nil + spec.derived_platform_node_keys_count = 0 buf[written] = spec written += 1 } @@ -4447,6 +4487,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { copyBytes(account.friendIdentityId, into: &spec.friend_identity_id) spec.account_xpub_bytes = nil spec.account_xpub_bytes_len = 0 + spec.derived_platform_node_keys = nil + spec.derived_platform_node_keys_count = 0 var pool = AccountAddressPoolFFI() pool.account = spec @@ -5143,7 +5185,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { case 8: return "Provider Voting Keys" case 9: return "Provider Owner Keys" case 10: return "Provider Operator Keys" - case 11: return "Provider Platform Keys" + case 11: return "Provider Platform Node Keys" case 12: return "DashPay Receiving Funds" case 13: return "DashPay External Account" case 14: return "Platform Payment" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift index 2ff655098a2..77c8953ab3e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftDashSDK import SwiftData +import UIKit // MARK: - Account Detail View struct AccountDetailView: View { @@ -16,6 +17,27 @@ struct AccountDetailView: View { @State private var showingPINPrompt = false @State private var pinInput = "" + // MARK: Provider derived-keys state + /// The #0..#19 keys derived from a provider account's extended + /// public key. Empty until loaded — eagerly for operator/BLS on + /// appear, straight from the persisted batch for platform-node/Ed25519 + /// wallets registered after pre-derivation shipped, and lazily behind + /// a button for older platform-node wallets (see `derivedKeysCard`). + @State private var derivedKeys: [ManagedPlatformWallet.ProviderDerivedKey] = [] + @State private var derivedKeysLoaded = false + @State private var isLoadingDerivedKeys = false + @State private var derivedKeysError: String? + /// Per-index revealed private-key hex, populated only after the user + /// confirms a reveal for that row. + @State private var revealedPrivateKeys: [UInt32: String] = [:] + /// Which row's reveal confirmation dialog is open (`nil` = none). + @State private var revealConfirmIndex: UInt32? + /// Which row is mid-reveal, to disable buttons and show progress. + @State private var revealingIndex: UInt32? + /// Copy-key of the derived-keys row just copied, for a transient + /// "Copied" confirmation. + @State private var derivedCopiedKey: String? + /// Distinct on-chain transactions this account participates in: /// the union of every TXO's creating tx and spending tx. Lives /// here rather than on the model because `PersistentTransaction` @@ -55,35 +77,46 @@ struct AccountDetailView: View { VStack(alignment: .leading, spacing: 20) { accountOverviewCard() - if shouldShowBalance { - balanceCard() - } - - poolSummaryCard() - - if account.accountType == 14 { - // PlatformPayment accounts keep their address - // list in `platformAddresses`, with no - // external/internal pool split. - let sorted = account.platformAddresses.sorted { - $0.addressIndex < $1.addressIndex - } - if sorted.isEmpty { - emptyAddressesCard() - } else { - platformAddressListCard(addresses: sorted) - } + if isProviderKeyAccount { + // Provider operator (BLS) / platform-node (EdDSA) + // key accounts hold key material, not on-chain + // addresses or a balance — surface the extended + // public key instead of an empty address pool. + extendedPublicKeyCard() + // ...and the per-index keys derived from it (the + // actual operator / platform-node keys). + derivedKeysCard() } else { - ForEach(addressSections(), id: \.0) { name, addresses in - addressListCard( - name: name, - systemImage: poolIcon(for: name), - addresses: addresses - ) + if shouldShowBalance { + balanceCard() } - if account.coreAddresses.isEmpty { - emptyAddressesCard() + poolSummaryCard() + + if account.accountType == 14 { + // PlatformPayment accounts keep their address + // list in `platformAddresses`, with no + // external/internal pool split. + let sorted = account.platformAddresses.sorted { + $0.addressIndex < $1.addressIndex + } + if sorted.isEmpty { + emptyAddressesCard() + } else { + platformAddressListCard(addresses: sorted) + } + } else { + ForEach(addressSections(), id: \.0) { name, addresses in + addressListCard( + name: name, + systemImage: poolIcon(for: name), + addresses: addresses + ) + } + + if account.coreAddresses.isEmpty { + emptyAddressesCard() + } } } } @@ -516,6 +549,346 @@ struct AccountDetailView: View { .cornerRadius(12) } + /// Extended-public-key card for provider key-material accounts + /// (`ProviderOperatorKeys` = BLS, `ProviderPlatformKeys` = EdDSA). + /// These accounts derive masternode / platform-node keys and hold + /// no on-chain addresses or balance, so the detail view surfaces + /// the persisted extended public key hex instead of an address + /// pool. The bytes are the bincode-encoded extended BLS / Ed25519 + /// public key the persister stored on `accountExtendedPubKeyBytes`. + private func extendedPublicKeyCard() -> some View { + let hex = account.accountExtendedPubKeyBytes? + .map { String(format: "%02x", $0) } + .joined() ?? "" + return VStack(alignment: .leading, spacing: 12) { + Label("Extended Public Key", systemImage: "key.horizontal.fill") + .font(.headline) + .foregroundColor(.primary) + + Divider() + + if hex.isEmpty { + Text("No extended public key has been persisted for this account yet. It lands here after the wallet is (re)created via `PlatformWalletManager`.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text(account.accountType == 10 + ? "Derives BLS operator keys for masternode operation. No on-chain addresses or balance." + : "Derives Ed25519 platform node keys. No on-chain addresses or balance.") + .font(.caption) + .foregroundColor(.secondary) + Text(hex) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + } + + // MARK: - Provider derived keys + + /// The per-index keys a provider account actually derives from its + /// extended public key: BLS operator keys (tag 10) or Ed25519 + /// platform-node keys (tag 11). Shows #0..#19, each with its public + /// key (and, for platform nodes, the 20-byte node id that a ProRegTx + /// carries) plus a confirm-gated private-key reveal. + /// + /// Loading policy follows the curve asymmetry: operator (BLS) public + /// keys derive from the account xpub with no mnemonic, so they load + /// eagerly on appear. Platform-node (Ed25519, SLIP-10 hardened-only) + /// keys need the seed even for their public key — but the batch is + /// pre-derived at registration and persisted, so they render straight + /// from the account row with no keychain prompt. Only wallets created + /// before pre-derivation shipped fall back to the lazy "Load Keys" + /// button. + private func derivedKeysCard() -> some View { + VStack(alignment: .leading, spacing: 12) { + Label("Derived Keys", systemImage: "key.fill") + .font(.headline) + .foregroundColor(.primary) + + Divider() + + if derivedKeysLoaded { + ForEach(Array(derivedKeys.enumerated()), id: \.element.index) { idx, key in + derivedKeyRow(key) + if idx < derivedKeys.count - 1 { + Divider() + } + } + } else if account.accountType == 11 { + // Ed25519 platform-node keys — gated behind an explicit + // unlock so no resolver / keychain read fires on nav. + Button { + loadDerivedKeys() + } label: { + HStack { + if isLoadingDerivedKeys { + ProgressView() + } else { + Image(systemName: "lock.open") + } + Text(isLoadingDerivedKeys ? "Loading…" : "Load Keys") + } + } + .disabled(isLoadingDerivedKeys) + + Text("Platform-node keys derive on the Ed25519 curve (hardened-only), so listing them needs the wallet mnemonic. Tap to unlock.") + .font(.caption) + .foregroundColor(.secondary) + } else { + // Operator (BLS) — loads on appear; brief placeholder. + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + } + + if let derivedKeysError { + Text(derivedKeysError) + .font(.caption) + .foregroundColor(.red) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + .onAppear { + guard !derivedKeysLoaded, !isLoadingDerivedKeys else { return } + // Platform-node (tag 11) keys pre-derived at registration are + // persisted on the account row — render them straight from + // persistence with no resolver / keychain read. Falls through + // to the "Load Keys" button only for wallets created before + // the batch was persisted. + if account.accountType == 11, !persistedPlatformNodeKeys.isEmpty { + derivedKeys = persistedPlatformNodeKeys + derivedKeysLoaded = true + return + } + // Operator (tag 10) public keys need no resolver — load them + // eagerly. A tag-11 account with no persisted batch waits for + // the button. + if account.accountType == 10 { + loadDerivedKeys() + } + } + } + + /// The persisted, pre-derived platform-node keys mapped into the + /// `ProviderDerivedKey` display shape (public key + node id as hex). + /// Empty for non-platform-node accounts and for wallets created + /// before the batch was persisted (those use the resolver-based + /// "Load Keys" fallback). Private keys stay `nil` here — a reveal + /// re-derives per index through `providerKeyAtIndex`. + private var persistedPlatformNodeKeys: [ManagedPlatformWallet.ProviderDerivedKey] { + account.derivedPlatformNodeKeys + .sorted { $0.index < $1.index } + .map { key in + ManagedPlatformWallet.ProviderDerivedKey( + index: key.index, + publicKeyHex: hexString(key.publicKey), + nodeIdHex: hexString(key.nodeId), + privateKeyHex: nil + ) + } + } + + /// Lowercase hex of raw bytes — matches the Rust FFI's hex encoding + /// so persisted rows render identically to resolver-derived ones. + private func hexString(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } + + /// One derived-key row: index header, public key, optional node id, + /// and a confirm-gated private-key reveal. All value rows are + /// monospaced, middle-truncated, and tap-to-copy. + @ViewBuilder + private func derivedKeyRow(_ key: ManagedPlatformWallet.ProviderDerivedKey) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Key #\(key.index)") + .font(.subheadline) + .fontWeight(.semibold) + + derivedValueRow( + label: account.accountType == 10 ? "BLS Public Key" : "Ed25519 Public Key", + value: key.publicKeyHex, + copyKey: "\(key.index)-pub" + ) + + if let nodeId = key.nodeIdHex { + derivedValueRow( + label: "Platform Node ID", + value: nodeId, + copyKey: "\(key.index)-node" + ) + } + + if let priv = revealedPrivateKeys[key.index] { + derivedValueRow( + label: "Private Key", + value: priv, + copyKey: "\(key.index)-priv" + ) + } else { + Button { + revealConfirmIndex = key.index + } label: { + HStack(spacing: 4) { + Image(systemName: "key.fill") + Text(revealingIndex == key.index ? "Revealing…" : "View Private Key") + } + .font(.caption) + } + .disabled(revealingIndex != nil) + } + } + .confirmationDialog( + "Reveal Private Key?", + isPresented: revealDialogBinding(for: key.index), + titleVisibility: .visible + ) { + Button("Reveal Private Key", role: .destructive) { + revealPrivateKey(index: key.index) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("The private key grants full control of this key. Only reveal it somewhere private.") + } + } + + /// One monospaced, middle-truncated value row with tap-to-copy and a + /// transient "Copied" confirmation keyed by `copyKey`. + @ViewBuilder + private func derivedValueRow(label: String, value: String, copyKey: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + if derivedCopiedKey == copyKey { + Label("Copied", systemImage: "checkmark") + .font(.caption2) + .foregroundColor(.green) + } else { + Image(systemName: "doc.on.doc") + .font(.caption2) + .foregroundColor(.accentColor) + } + } + Text(value) + .font(.system(.caption2, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(Rectangle()) + .onTapGesture { copyDerived(value, copyKey: copyKey) } + } + + private func revealDialogBinding(for index: UInt32) -> Binding { + Binding( + get: { revealConfirmIndex == index }, + set: { open in + if !open, revealConfirmIndex == index { revealConfirmIndex = nil } + } + ) + } + + /// Derive the #0..#19 public keys for this provider account. All + /// derivation happens on the Rust side (one FFI call per index); for + /// platform-node accounts the mnemonic is pulled on demand via the + /// resolver and never enters Swift. Only reached for operator (BLS) + /// accounts and legacy platform-node wallets with no persisted batch. + private func loadDerivedKeys() { + guard let managed = walletManager.wallet(for: wallet.walletId) else { + derivedKeysError = "The owning wallet is not loaded." + return + } + guard let kind = ManagedPlatformWallet.ProviderKeyKind( + rawValue: UInt8(account.accountType) + ) else { + derivedKeysError = "Unsupported provider account type." + return + } + isLoadingDerivedKeys = true + derivedKeysError = nil + Task { + do { + var keys: [ManagedPlatformWallet.ProviderDerivedKey] = [] + // Matches `PLATFORM_NODE_KEY_PREDERIVE_COUNT` on the Rust + // side so the resolver fallback lists the same window the + // persisted batch would have shown. + for index in UInt32(0)..<20 { + keys.append( + try managed.providerKeyAtIndex( + kind: kind, + index: index, + includePrivate: false + ) + ) + } + let loaded = keys + await MainActor.run { + derivedKeys = loaded + derivedKeysLoaded = true + isLoadingDerivedKeys = false + } + } catch { + await MainActor.run { + derivedKeysError = error.localizedDescription + isLoadingDerivedKeys = false + } + } + } + } + + /// Reveal the private key for one derived-key row. Re-derives at that + /// index with `includePrivate: true`; Rust returns the raw scalar + /// hex (BLS / Ed25519 keys have no WIF). + private func revealPrivateKey(index: UInt32) { + guard let managed = walletManager.wallet(for: wallet.walletId) else { + derivedKeysError = "The owning wallet is not loaded." + return + } + guard let kind = ManagedPlatformWallet.ProviderKeyKind( + rawValue: UInt8(account.accountType) + ) else { return } + revealingIndex = index + derivedKeysError = nil + Task { + do { + let key = try managed.providerKeyAtIndex( + kind: kind, + index: index, + includePrivate: true + ) + let hex = key.privateKeyHex + await MainActor.run { + if let hex { revealedPrivateKeys[index] = hex } + revealingIndex = nil + } + } catch { + await MainActor.run { + derivedKeysError = error.localizedDescription + revealingIndex = nil + } + } + } + } + + private func copyDerived(_ value: String, copyKey: String) { + UIPasteboard.general.string = value + derivedCopiedKey = copyKey + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if derivedCopiedKey == copyKey { derivedCopiedKey = nil } + } + } + private func poolIcon(for name: String) -> String { switch name { case "External": return "arrow.down.circle" @@ -533,6 +906,12 @@ struct AccountDetailView: View { } } + /// Provider operator (BLS, tag 10) / platform-node (EdDSA, tag 11) + /// key-material accounts. They hold key material, not addresses. + private var isProviderKeyAccount: Bool { + account.accountType == 10 || account.accountType == 11 + } + private func formatBalance(_ amount: UInt64) -> String { let dash = Double(amount) / 100_000_000.0 let formatter = NumberFormatter() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index d1ad06b82f6..fc5ba172579 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData import SwiftDashSDK +import UIKit // MARK: - Shared Helpers @@ -1555,6 +1556,18 @@ struct AccountStorageDetailView: View { struct CoreAddressDetailView: View { let record: PersistentCoreAddress + @EnvironmentObject private var walletManager: PlatformWalletManager + + /// The revealed key material, held only after the user confirms. + /// `nil` keeps the section in its "View Private Key" gated state. + @State private var privateKey: ManagedPlatformWallet.CoreAddressPrivateKey? + @State private var showRevealConfirm = false + @State private var isRevealing = false + @State private var revealError: String? + /// Label of the row whose value was just copied, for a transient + /// "Copied" confirmation. + @State private var copiedLabel: String? + var body: some View { Form { Section("Address") { @@ -1572,6 +1585,7 @@ struct CoreAddressDetailView: View { : record.publicKey.map { String(format: "%02x", $0) }.joined() ) } + privateKeySection Section("Balance / Activity") { FieldRow(label: "Balance", value: "\(record.balance)") FieldRow( @@ -1595,6 +1609,127 @@ struct CoreAddressDetailView: View { .navigationTitle("Address") .navigationBarTitleDisplayMode(.inline) } + + /// Reveal-gated private-key section. Before reveal it shows a single + /// "View Private Key" button that pops a confirmation dialog (this is + /// a developer example app, so a plain confirm — no biometrics — is + /// enough). After the user confirms, the derived hex + WIF are shown + /// monospaced with tap-to-copy. + @ViewBuilder + private var privateKeySection: some View { + Section("Private Key") { + if let key = privateKey { + copyableKeyRow(label: "Hex", value: key.hex) + copyableKeyRow(label: "WIF", value: key.wif) + Text("Anyone with this key controls this address's funds. Never share it.") + .font(.caption2) + .foregroundColor(.secondary) + } else { + Button { + showRevealConfirm = true + } label: { + HStack { + Image(systemName: "key.fill") + Text(isRevealing ? "Revealing…" : "View Private Key") + } + } + .disabled(isRevealing) + + if let revealError { + Text(revealError) + .font(.caption) + .foregroundColor(.red) + } + } + } + .confirmationDialog( + "Reveal Private Key?", + isPresented: $showRevealConfirm, + titleVisibility: .visible + ) { + Button("Reveal Private Key", role: .destructive) { reveal() } + Button("Cancel", role: .cancel) {} + } message: { + Text("The private key grants full control of this address's funds. Only reveal it somewhere private.") + } + } + + /// One monospaced key row (hex or WIF) with tap-to-copy and a + /// transient "Copied" confirmation. + @ViewBuilder + private func copyableKeyRow(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label).foregroundColor(.secondary) + Spacer() + if copiedLabel == label { + Label("Copied", systemImage: "checkmark") + .font(.caption2) + .foregroundColor(.green) + } else { + Image(systemName: "doc.on.doc") + .font(.caption) + .foregroundColor(.accentColor) + } + } + Text(value) + .font(.system(.footnote, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(Rectangle()) + .onTapGesture { copy(value, label: label) } + } + + /// Look up the owning wallet and ask Rust to derive this address's + /// private key. All derivation happens on the Rust side; the mnemonic + /// is pulled on demand via the resolver and never enters Swift. + private func reveal() { + guard let walletId = record.account?.wallet.walletId else { + revealError = "This address is not linked to a wallet." + return + } + guard let wallet = walletManager.wallet(for: walletId) else { + revealError = "The owning wallet is not loaded." + return + } + isRevealing = true + revealError = nil + // Off the main thread: the synchronous FFI's resolver reads the + // iOS Keychain, which can stall. Mirrors + // `AccountDetailView.revealPrivateKey(index:)`. + Task { + do { + let key = try wallet.coreAddressPrivateKey(address: record.address) + await MainActor.run { + privateKey = key + isRevealing = false + } + } catch { + await MainActor.run { + revealError = error.localizedDescription + isRevealing = false + } + } + } + } + + private func copy(_ value: String, label: String) { + // This copies a raw private key / WIF to the system-wide + // pasteboard, which other apps and clipboard managers can read and + // Universal Clipboard syncs across devices. Set a short expiry so + // the secret doesn't linger there indefinitely. Fine for this demo + // app; a production wallet should avoid clipboard export of secrets + // (or gate it far more tightly). + UIPasteboard.general.setItems( + [["public.utf8-plain-text": value]], + options: [.expirationDate: Date().addingTimeInterval(60)] + ) + copiedLabel = label + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if copiedLabel == label { copiedLabel = nil } + } + } } // MARK: - PersistentTransaction