From db29dbb252f7502b27f08fe79caca8e5968b5ec4 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 16:33:04 -0500 Subject: [PATCH 1/2] feat(sdk)!: thread an optional BIP-39 passphrase through the mnemonic resolver and wallet creation Every seed derivation used to hardcode the empty passphrase: key-wallet's Mnemonic wallet variant calls to_seed(""), and the Swift-to-Rust mnemonic resolver vtable could only return the mnemonic. A host that wanted a BIP-39 passphrase ("25th word") wallet had no way to create one that would sign after relaunch. The resolver callback now also returns the wallet's stored passphrase (length 0 = none). A single resolve_seed helper in rs-sdk-ffi is the only consumer of the vtable, so the passphrase cannot be honoured on one derivation path and dropped on another; the core signer, the resolver-driven sign FFI, identity derive-and-persist, identity discovery/preview/loading, and the at-slot preview all route through it. create_wallet_from_mnemonic takes a passphrase and builds a Seed-typed key-wallet (same network-scoped id as the mnemonic variant for the empty passphrase, verified by test). New FFI exports: platform_wallet_manager_create_wallet_from_mnemonic_with_passphrase_and_birth_height and an any-language platform_wallet_mnemonic_to_seed. Swift SDK: WalletStorage stores the passphrase as its own per-wallet Keychain item next to the mnemonic and folds it into the seed-binding stamp; MnemonicResolver fills the passphrase buffer; PlatformWalletManager.createWallet(mnemonic:seedPassphrase:) on both overloads; deleteWallet removes the passphrase item; Mnemonic.toSeed uses the any-language FFI. The throwing passphrase parameter on WalletManager.addWalletAndSerialize is removed. BREAKING: MnemonicResolveCallback gains three trailing parameters; PlatformWalletManager::create_wallet_from_mnemonic gains a passphrase argument. The Android JNI trampoline reports no passphrase, so Kotlin behaviour is unchanged. Co-Authored-By: Claude Fable 5.1 --- .../src/core_wallet/sign_message.rs | 3 + .../src/derive_identity_key_at_slot.rs | 84 +--- .../src/identity_derive_and_persist.rs | 83 +--- .../src/identity_keys_from_mnemonic.rs | 111 +++-- .../rs-platform-wallet-ffi/src/manager.rs | 166 ++++++++ .../src/masternode_withdrawal.rs | 3 + .../src/sign_with_mnemonic_resolver.rs | 171 +++++--- .../examples/dpns_marketplace_testnet.rs | 1 + packages/rs-platform-wallet/src/lib.rs | 1 + .../rs-platform-wallet/src/manager/mod.rs | 1 + .../src/manager/wallet_lifecycle.rs | 94 ++++- .../src/wallet/identity/network/discovery.rs | 4 +- .../src/wallet/identity/network/loading.rs | 4 +- packages/rs-sdk-ffi/src/mnemonic_resolver.rs | 381 +++++++++++++++++- .../src/mnemonic_resolver_core_signer.rs | 101 +++-- packages/rs-unified-sdk-jni/src/mnemonic.rs | 13 +- .../Core/Wallet/WalletStorage.swift | 150 ++++++- .../FFI/MnemonicResolverAndPersister.swift | 119 ++++-- .../SwiftDashSDK/KeyWallet/Mnemonic.swift | 56 ++- .../Sources/SwiftDashSDK/KeyWallet/README.md | 6 +- .../KeyWallet/WalletManager.swift | 20 +- .../PlatformWalletManager.swift | 97 +++-- .../SwiftDashSDK/PlatformWallet/README.md | 12 +- .../SwiftExampleApp/ContentView.swift | 1 + .../PlatformWalletCreateWalletTests.swift | 18 +- .../SeedPassphraseResolverTests.swift | 197 +++++++++ 26 files changed, 1475 insertions(+), 422 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SeedPassphraseResolverTests.swift diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/sign_message.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/sign_message.rs index 6c39057fbf6..21be322996f 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/sign_message.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/sign_message.rs @@ -192,6 +192,9 @@ mod tests { _out_buf: *mut c_char, _out_capacity: usize, _out_len: *mut usize, + _out_passphrase: *mut c_char, + _out_passphrase_capacity: usize, + _out_passphrase_len: *mut usize, ) -> i32 { unreachable!("the handle is rejected long before any mnemonic is resolved"); } diff --git a/packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs b/packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs index ed2463e3cdc..09e2116952b 100644 --- a/packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs +++ b/packages/rs-platform-wallet-ffi/src/derive_identity_key_at_slot.rs @@ -1,7 +1,6 @@ //! Single-slot mnemonic-driven identity-authentication key derivation. -use std::ffi::{c_void, CString}; -use std::os::raw::c_char; +use std::ffi::CString; use crate::types::{FFINetwork, Network}; use dashcore::PrivateKey as DashPrivateKey; @@ -11,11 +10,11 @@ use zeroize::Zeroizing; use crate::error::*; use crate::identity_key_preview::IdentityKeyPreviewFFI; -use crate::identity_keys_from_mnemonic::{parse_mnemonic_any_language, zeroize_and_free_row}; -use crate::{check_ptr, unwrap_result_or_return}; -use rs_sdk_ffi::{ - mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, +use crate::identity_keys_from_mnemonic::{ + parse_mnemonic_any_language, resolve_seed_from_resolver, zeroize_and_free_row, }; +use crate::{check_ptr, unwrap_result_or_return}; +use rs_sdk_ffi::MnemonicResolverHandle; /// Derive a single ECDSA identity-authentication keypair at /// `(identity_index, key_index)` from a BIP-39 mnemonic. @@ -42,27 +41,20 @@ pub unsafe extern "C" fn dash_sdk_derive_identity_key_at_slot( unwrap_result_or_return!(CStr::from_ptr(passphrase_cstr).to_str()) }; - derive_at_slot_inner( - mnemonic_str, - passphrase_str, - network, - identity_index, - key_index, - out_row, - ) + let mnemonic = unwrap_result_or_return!(parse_mnemonic_any_language(mnemonic_str)); + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed(passphrase_str)); + drop(mnemonic); + + derive_at_slot_inner(&seed, network, identity_index, key_index, out_row) } unsafe fn derive_at_slot_inner( - mnemonic_str: &str, - passphrase_str: &str, + seed: &Zeroizing<[u8; 64]>, network: FFINetwork, identity_index: u32, key_index: u32, out_row: *mut IdentityKeyPreviewFFI, ) -> PlatformWalletFFIResult { - let mnemonic = unwrap_result_or_return!(parse_mnemonic_any_language(mnemonic_str)); - let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed(passphrase_str)); - let kw_network: Network = network.into(); let master = unwrap_result_or_return!(ExtendedPrivKey::new_master(kw_network, seed.as_ref())); @@ -145,51 +137,17 @@ pub unsafe extern "C" fn dash_sdk_derive_identity_key_at_slot_with_resolver( check_ptr!(wallet_id_bytes); check_ptr!(mnemonic_resolver_handle); - let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = - Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); - let mut mnemonic_len: usize = 0; - - let resolver = &*mnemonic_resolver_handle; - let resolver_vtable = &*resolver.vtable; - let rc = (resolver_vtable.resolve)( - resolver.ctx as *const c_void, - wallet_id_bytes, - mnemonic_buf.as_mut_ptr() as *mut c_char, - MNEMONIC_RESOLVER_BUFFER_CAPACITY, - &mut mnemonic_len, - ); - match rc { - x if x == mnemonic_resolver_result::SUCCESS => {} - x if x == mnemonic_resolver_result::NOT_FOUND => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: no mnemonic stored for the supplied wallet_id", - ); - } - x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: mnemonic exceeded the FFI buffer capacity", - ); - } - _ => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: failed (other / Keychain access error)", - ); - } - } - - let mnemonic_str = unwrap_result_or_return!(std::str::from_utf8(&mnemonic_buf[..mnemonic_len])); + // Shared vtable consumer: folds in the wallet's stored BIP-39 + // passphrase, so the slot preview matches what the wallet signs with. + let seed: Zeroizing<[u8; 64]> = match resolve_seed_from_resolver( + mnemonic_resolver_handle, + &*(wallet_id_bytes as *const [u8; 32]), + ) { + Ok(seed) => seed, + Err(result) => return result, + }; - derive_at_slot_inner( - mnemonic_str, - "", - network, - identity_index, - key_index, - out_row, - ) + derive_at_slot_inner(&seed, network, identity_index, key_index, out_row) } /// Free a row populated by [`dash_sdk_derive_identity_key_at_slot`]. diff --git a/packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs b/packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs index 9ce63c81f56..6e21c678554 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_derive_and_persist.rs @@ -88,7 +88,6 @@ //! call without performing a second derivation pass. use std::ffi::{c_void, CString}; -use std::os::raw::c_char; use std::ptr; use crate::types::{FFINetwork, Network}; @@ -103,13 +102,11 @@ use crate::derive_and_persist_callbacks::{ use crate::error::*; use crate::identity_key_preview::IdentityKeyPreviewFFI; use crate::identity_keys_from_mnemonic::{ - identity_auth_derivation_path, parse_mnemonic_any_language, + identity_auth_derivation_path, resolve_seed_from_resolver, }; use crate::identity_registration_with_signer::IdentityRegistrationKeyDerivationsFFI; use crate::{check_ptr, unwrap_result_or_return}; -use rs_sdk_ffi::{ - mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, -}; +use rs_sdk_ffi::MnemonicResolverHandle; /// DPP `KeyType::ECDSA_SECP256K1` discriminant byte. const KEY_TYPE_ECDSA_SECP256K1: u8 = 0; @@ -201,62 +198,16 @@ pub unsafe extern "C" fn dash_sdk_derive_and_persist_identity_keys( return PlatformWalletFFIResult::ok(); } - // ---- Resolve mnemonic ---------------------------------------------------- - // Stack-resident, zeroized-on-drop buffer the resolver writes into. - let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = - Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); - let mut mnemonic_len: usize = 0; - - let resolver = &*mnemonic_resolver_handle; - let resolver_vtable = &*resolver.vtable; - let rc = (resolver_vtable.resolve)( - resolver.ctx as *const c_void, - wallet_id_bytes, - mnemonic_buf.as_mut_ptr() as *mut c_char, - MNEMONIC_RESOLVER_BUFFER_CAPACITY, - &mut mnemonic_len, - ); - match rc { - x if x == mnemonic_resolver_result::SUCCESS => {} - x if x == mnemonic_resolver_result::NOT_FOUND => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: no mnemonic stored for the supplied wallet_id", - ); - } - x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: mnemonic exceeded the FFI buffer capacity", - ); - } - _ => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: failed (other / Keychain access error)", - ); - } - } - if mnemonic_len == 0 || mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: returned invalid length", - ); - } - - // Validate UTF-8 once over the prefix the resolver claimed to - // write. Done in-place so we never construct a `String` - // (Swift's String can't be zeroized; ours can). - let mnemonic_str = unwrap_result_or_return!(std::str::from_utf8(&mnemonic_buf[..mnemonic_len])); - let mnemonic = unwrap_result_or_return!(parse_mnemonic_any_language(mnemonic_str)); - - // ---- Derive seed + master xpriv ------------------------------------------ - // Empty passphrase to mirror the rest of the SDK (no caller - // surface for a BIP-39 passphrase yet). - let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); - // Mnemonic is no longer needed; explicit drop releases its - // (non-zeroized) `String` storage early. - drop(mnemonic); + // ---- Resolve seed (mnemonic + stored passphrase) ------------------------- + // Shared vtable consumer: the seed comes back in a `Zeroizing` buffer + // and already folds in the wallet's BIP-39 passphrase, if it has one. + let seed: Zeroizing<[u8; 64]> = match resolve_seed_from_resolver( + mnemonic_resolver_handle, + &*(wallet_id_bytes as *const [u8; 32]), + ) { + Ok(seed) => seed, + Err(result) => return result, + }; let kw_network: Network = network.into(); let master = unwrap_result_or_return!(ExtendedPrivKey::new_master(kw_network, seed.as_ref())); @@ -436,8 +387,12 @@ mod tests { use crate::derive_and_persist_callbacks::{ dash_sdk_identity_key_persister_create, dash_sdk_identity_key_persister_destroy, }; - use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; + use rs_sdk_ffi::{ + dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy, + mnemonic_resolver_result, + }; use std::ffi::CStr; + use std::os::raw::c_char; use std::sync::Mutex; /// English BIP-39 test vector (all-zero entropy). Same fixture @@ -476,6 +431,9 @@ mod tests { out_buf: *mut c_char, out_capacity: usize, out_len: *mut usize, + _out_passphrase: *mut c_char, + _out_passphrase_capacity: usize, + out_passphrase_len: *mut usize, ) -> i32 { let phrase = ENGLISH_PHRASE.as_bytes(); if phrase.len() + 1 > out_capacity { @@ -484,6 +442,7 @@ mod tests { std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); *out_buf.add(phrase.len()) = 0; *out_len = phrase.len(); + *out_passphrase_len = 0; mnemonic_resolver_result::SUCCESS } diff --git a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs index ef4ad2bf93d..577b038761b 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs @@ -169,9 +169,9 @@ pub(crate) unsafe fn resolve_master_from_resolver_classified( }) } -/// Resolve a wallet's BIP-39 mnemonic via a Swift-owned -/// [`MnemonicResolverHandle`] and return the **raw 64-byte BIP39 seed** -/// (empty passphrase). +/// Resolve a wallet's BIP-39 mnemonic (and its stored passphrase, if +/// any) via a Swift-owned [`MnemonicResolverHandle`] and return the +/// **raw 64-byte BIP39 seed**. /// /// This is the seed the BLS operator / Ed25519 platform-node HD masters /// consume directly (rust-dashcore #879); unlike @@ -202,73 +202,38 @@ pub(crate) unsafe fn resolve_seed_from_resolver_classified( mnemonic_resolver_handle: *mut rs_sdk_ffi::MnemonicResolverHandle, wallet_id: &[u8; 32], ) -> Result, ResolveFailure> { - use rs_sdk_ffi::{mnemonic_resolver_result, MNEMONIC_RESOLVER_BUFFER_CAPACITY}; - use std::ffi::c_void; - - let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = - Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); - let mut mnemonic_len: usize = 0; - - let resolver = &*mnemonic_resolver_handle; - let resolver_vtable = &*resolver.vtable; - let rc = (resolver_vtable.resolve)( - resolver.ctx as *const c_void, - wallet_id.as_ptr(), - mnemonic_buf.as_mut_ptr() as *mut std::os::raw::c_char, - MNEMONIC_RESOLVER_BUFFER_CAPACITY, - &mut mnemonic_len, - ); - match rc { - x if x == mnemonic_resolver_result::SUCCESS => {} - x if x == mnemonic_resolver_result::NOT_FOUND => { - // Not permanent: the host filters watch-only wallets before - // calling, so reaching this means the item was expected and was - // not readable — including the wipe/restore race. - return Err(ResolveFailure::unavailable(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: no mnemonic stored for the supplied wallet_id", - ))); - } - x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { - return Err(ResolveFailure::permanent(PlatformWalletFFIResult::err( + use rs_sdk_ffi::ResolveSeedError; + + rs_sdk_ffi::resolve_seed(mnemonic_resolver_handle, wallet_id).map_err(|e| match e { + // Not permanent: the host filters watch-only wallets before + // calling, so reaching this means the item was expected and was + // not readable — including the wipe/restore race. + ResolveSeedError::NotFound => ResolveFailure::unavailable(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + e.to_string(), + )), + // The Keychain bucket: locked device, denied or cancelled prompt, + // daemon unavailable. Retryable by nature. + ResolveSeedError::ResolverFailed(_) => { + ResolveFailure::unavailable(PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: mnemonic exceeded the FFI buffer capacity", - ))); + e.to_string(), + )) } - _ => { - // The Keychain bucket: locked device, denied or cancelled prompt, - // daemon unavailable. Retryable by nature. - return Err(ResolveFailure::unavailable(PlatformWalletFFIResult::err( + ResolveSeedError::InvalidUtf8 => ResolveFailure::permanent(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + e.to_string(), + )), + ResolveSeedError::BufferTooSmall + | ResolveSeedError::InvalidMnemonicLength(_) + | ResolveSeedError::InvalidPassphraseLength(_) + | ResolveSeedError::InvalidMnemonic => { + ResolveFailure::permanent(PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: failed (other / Keychain access error)", - ))); + e.to_string(), + )) } - } - if mnemonic_len == 0 || mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { - return Err(ResolveFailure::permanent(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "mnemonic resolver: returned invalid length", - ))); - } - - // Validate UTF-8 over the resolver-claimed prefix only — never - // build a `String` (Swift's can't be zeroized; ours can). - let mnemonic_str = std::str::from_utf8(&mnemonic_buf[..mnemonic_len]).map_err(|e| { - ResolveFailure::permanent(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorUtf8Conversion, - format!("mnemonic resolver: returned invalid UTF-8: {e}"), - )) - })?; - let mnemonic = parse_mnemonic_any_language(mnemonic_str).map_err(|e| { - ResolveFailure::permanent(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - format!("mnemonic resolver: returned an invalid mnemonic: {e}"), - )) - })?; - - let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); - drop(mnemonic); - Ok(seed) + }) } /// Build the DIP-9 identity-authentication derivation path @@ -616,6 +581,9 @@ mod resolve_classification_tests { _out: *mut c_char, _cap: usize, _out_len: *mut usize, + _out_pp: *mut c_char, + _pp_cap: usize, + _out_pp_len: *mut usize, ) -> i32 { mnemonic_resolver_result::NOT_FOUND } @@ -627,6 +595,9 @@ mod resolve_classification_tests { _out: *mut c_char, _cap: usize, _out_len: *mut usize, + _out_pp: *mut c_char, + _pp_cap: usize, + _out_pp_len: *mut usize, ) -> i32 { mnemonic_resolver_result::OTHER } @@ -638,11 +609,15 @@ mod resolve_classification_tests { out: *mut c_char, cap: usize, out_len: *mut usize, + _out_pp: *mut c_char, + _pp_cap: usize, + out_pp_len: *mut usize, ) -> i32 { let phrase = b"not a bip39 phrase at all"; assert!(cap >= phrase.len()); std::ptr::copy_nonoverlapping(phrase.as_ptr(), out as *mut u8, phrase.len()); *out_len = phrase.len(); + *out_pp_len = 0; mnemonic_resolver_result::SUCCESS } @@ -653,8 +628,12 @@ mod resolve_classification_tests { _out: *mut c_char, _cap: usize, out_len: *mut usize, + _out_pp: *mut c_char, + _pp_cap: usize, + out_pp_len: *mut usize, ) -> i32 { *out_len = MNEMONIC_RESOLVER_BUFFER_CAPACITY + 1; + *out_pp_len = 0; mnemonic_resolver_result::SUCCESS } diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 84ef9bddac9..00a761366ed 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -474,13 +474,29 @@ unsafe fn create_wallet_from_seed_impl( PlatformWalletFFIResult::ok() } +/// Read an optional BIP-39 passphrase C string. `NULL` means "no +/// passphrase" and maps to the empty string, which is the BIP-39 default +/// and what every pre-passphrase caller gets. +unsafe fn passphrase_str<'a>( + passphrase: *const std::os::raw::c_char, +) -> Result<&'a str, std::str::Utf8Error> { + if passphrase.is_null() { + Ok("") + } else { + std::ffi::CStr::from_ptr(passphrase).to_str() + } +} + /// Shared body for the mnemonic-based wallet-creation exports. /// /// `birth_height_override` is threaded verbatim into /// `create_wallet_from_mnemonic`; the no-override export passes `None`. +/// `passphrase` may be `NULL` (no passphrase). +#[allow(clippy::too_many_arguments)] unsafe fn create_wallet_from_mnemonic_impl( manager_handle: Handle, mnemonic: *const std::os::raw::c_char, + passphrase: *const std::os::raw::c_char, network: FFINetwork, account_options: u32, birth_height_override: Option, @@ -492,6 +508,7 @@ unsafe fn create_wallet_from_mnemonic_impl( check_ptr!(out_wallet_id); let mnemonic_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(mnemonic).to_str()); + let passphrase_str = unwrap_result_or_return!(passphrase_str(passphrase)); let network: Network = network.into(); @@ -503,6 +520,7 @@ unsafe fn create_wallet_from_mnemonic_impl( let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { runtime().block_on(manager.create_wallet_from_mnemonic( mnemonic_str, + passphrase_str, network, accounts, birth_height_override, @@ -594,6 +612,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic( create_wallet_from_mnemonic_impl( manager_handle, mnemonic, + std::ptr::null(), network, account_options, None, @@ -627,6 +646,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic_wit create_wallet_from_mnemonic_impl( manager_handle, mnemonic, + std::ptr::null(), network, account_options, birth_height_override_opt(has_birth_height_override, birth_height_override), @@ -635,6 +655,85 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic_wit ) } +/// Create a wallet from a BIP39 mnemonic phrase plus an optional BIP-39 +/// passphrase ("25th word"), with an optional birth-height override. +/// +/// Identical to +/// [`platform_wallet_manager_create_wallet_from_mnemonic_with_birth_height`] +/// except for `passphrase`: `NULL` or `""` means no passphrase and yields +/// exactly the wallet (and wallet id) that export produces; any other +/// value is folded into the seed as `PBKDF2(mnemonic, passphrase)`, so the +/// resulting wallet id differs from the passphrase-less one. The passphrase +/// is NFKD-normalized per BIP-39 — pass it as the user typed it. +/// +/// The host must store the passphrase alongside the mnemonic and hand both +/// back through its `MnemonicResolveCallback`, or the wallet will not sign +/// with the keys it was created with. +/// +/// On success, `out_wallet_handle` is set to a `PlatformWallet` handle and +/// `out_wallet_id` is filled with the 32-byte wallet ID. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic_with_passphrase_and_birth_height( + manager_handle: Handle, + mnemonic: *const std::os::raw::c_char, + passphrase: *const std::os::raw::c_char, + network: FFINetwork, + account_options: u32, + has_birth_height_override: bool, + birth_height_override: u32, + out_wallet_handle: *mut Handle, + out_wallet_id: *mut [u8; 32], +) -> PlatformWalletFFIResult { + create_wallet_from_mnemonic_impl( + manager_handle, + mnemonic, + passphrase, + network, + account_options, + birth_height_override_opt(has_birth_height_override, birth_height_override), + out_wallet_handle, + out_wallet_id, + ) +} + +/// Derive the 64-byte BIP-39 seed for `(mnemonic, passphrase)` without +/// creating a wallet. Language is auto-detected across every supported +/// wordlist (unlike key-wallet-ffi's English-only `mnemonic_to_seed`), and +/// `passphrase` may be `NULL` (no passphrase). Lets a host pre-derive the +/// wallet id a passphrase create would produce (`Wallet::from_seed(...) +/// .id`) before it commits any secret to storage. +/// +/// # Safety +/// `mnemonic` must be a valid NUL-terminated UTF-8 C string; `passphrase` +/// must be `NULL` or a valid NUL-terminated UTF-8 C string; `out_seed` must +/// be writable for `out_seed_len == 64` bytes. The caller owns the output +/// and should scrub it once it has been consumed. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_mnemonic_to_seed( + mnemonic: *const std::os::raw::c_char, + passphrase: *const std::os::raw::c_char, + out_seed: *mut u8, + out_seed_len: usize, +) -> PlatformWalletFFIResult { + check_ptr!(mnemonic); + check_ptr!(out_seed); + if out_seed_len != 64 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("out_seed must be 64 bytes, got {out_seed_len}"), + ); + } + let mnemonic_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(mnemonic).to_str()); + let passphrase_str = unwrap_result_or_return!(passphrase_str(passphrase)); + let seed = match platform_wallet::seed_from_mnemonic(mnemonic_str, passphrase_str) { + Ok(seed) => seed, + Err(e) => return e.into(), + }; + std::ptr::copy_nonoverlapping(seed.as_ptr(), out_seed, 64); + PlatformWalletFFIResult::ok() +} + /// Hydrate the manager from its persister. /// /// Triggers `on_load_wallet_list_fn` on the persistence callbacks to @@ -1080,6 +1179,73 @@ mod tests { out } + /// BIP-39 reference vectors (all-`abandon` phrase): the passphrase-less + /// seed and the `TREZOR` seed. Pins the FFI's NULL-means-empty contract + /// and that the passphrase actually reaches PBKDF2. + #[test] + fn mnemonic_to_seed_matches_bip39_vectors_with_and_without_passphrase() { + let phrase = std::ffi::CString::new( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .unwrap(); + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + + let mut seed = [0u8; 64]; + let rc = unsafe { + platform_wallet_mnemonic_to_seed( + phrase.as_ptr(), + std::ptr::null(), + seed.as_mut_ptr(), + seed.len(), + ) + }; + assert_eq!(rc.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + hex(&seed), + "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4" + ); + + let empty = std::ffi::CString::new("").unwrap(); + let mut seed_empty = [0u8; 64]; + let rc = unsafe { + platform_wallet_mnemonic_to_seed( + phrase.as_ptr(), + empty.as_ptr(), + seed_empty.as_mut_ptr(), + seed_empty.len(), + ) + }; + assert_eq!(rc.code, PlatformWalletFFIResultCode::Success); + assert_eq!(seed_empty, seed, "an empty passphrase must equal NULL"); + + let trezor = std::ffi::CString::new("TREZOR").unwrap(); + let mut seed_pp = [0u8; 64]; + let rc = unsafe { + platform_wallet_mnemonic_to_seed( + phrase.as_ptr(), + trezor.as_ptr(), + seed_pp.as_mut_ptr(), + seed_pp.len(), + ) + }; + assert_eq!(rc.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + hex(&seed_pp), + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + ); + + let mut short = [0u8; 32]; + let rc = unsafe { + platform_wallet_mnemonic_to_seed( + phrase.as_ptr(), + std::ptr::null(), + short.as_mut_ptr(), + short.len(), + ) + }; + assert_eq!(rc.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + } + #[test] fn birth_height_override_opt_true_zero_is_some_zero() { assert_eq!(birth_height_override_opt(true, 0), Some(0)); diff --git a/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs b/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs index 4b83446381d..06546f25421 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs @@ -281,6 +281,9 @@ mod tests { _out_buf: *mut c_char, _out_capacity: usize, _out_len: *mut usize, + _out_passphrase: *mut c_char, + _out_passphrase_capacity: usize, + _out_passphrase_len: *mut usize, ) -> i32 { unreachable!("rejected before any mnemonic is resolved"); } diff --git a/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs b/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs index 0225e653a77..ebbc918cb85 100644 --- a/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs +++ b/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs @@ -36,7 +36,7 @@ //! see (or care) which Rust crate produced //! `dash_sdk_sign_with_mnemonic_resolver_and_path`. -use std::ffi::{c_void, CStr}; +use std::ffi::CStr; use std::os::raw::c_char; use std::str::FromStr; @@ -45,10 +45,7 @@ use dashcore::secp256k1::Secp256k1; use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; use zeroize::Zeroizing; -use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; -use rs_sdk_ffi::{ - mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, -}; +use rs_sdk_ffi::{resolve_seed, MnemonicResolverHandle, ResolveSeedError}; // One-byte error tags. Mirror the shape of // `signer_simple::SIGN_WITH_MNEMONIC_ERR_*` so call sites already @@ -213,50 +210,27 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_resolver_and_path( return fail(SIGN_WITH_RESOLVER_ERR_UNSUPPORTED_KEY_TYPE); } - // ---- Resolve mnemonic ---------------------------------------------------- - let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = - Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); - let mut mnemonic_len: usize = 0; - - let resolver = &*mnemonic_resolver_handle; - let resolver_vtable = &*resolver.vtable; - let rc = (resolver_vtable.resolve)( - resolver.ctx as *const c_void, - wallet_id_bytes, - mnemonic_buf.as_mut_ptr() as *mut c_char, - MNEMONIC_RESOLVER_BUFFER_CAPACITY, - &mut mnemonic_len, - ); - match rc { - x if x == mnemonic_resolver_result::SUCCESS => {} - x if x == mnemonic_resolver_result::NOT_FOUND => { - return fail(SIGN_WITH_RESOLVER_ERR_RESOLVER_NOT_FOUND); + // ---- Resolve seed (mnemonic + stored passphrase) ------------------------- + let seed: Zeroizing<[u8; 64]> = match resolve_seed( + mnemonic_resolver_handle, + &*(wallet_id_bytes as *const [u8; 32]), + ) { + Ok(seed) => seed, + Err(ResolveSeedError::NotFound) => return fail(SIGN_WITH_RESOLVER_ERR_RESOLVER_NOT_FOUND), + Err(ResolveSeedError::BufferTooSmall) => { + return fail(SIGN_WITH_RESOLVER_ERR_BUFFER_TOO_SMALL) } - x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { - return fail(SIGN_WITH_RESOLVER_ERR_BUFFER_TOO_SMALL); + Err(ResolveSeedError::InvalidUtf8) => return fail(SIGN_WITH_RESOLVER_ERR_INVALID_UTF8), + Err(ResolveSeedError::InvalidMnemonic) => { + return fail(SIGN_WITH_RESOLVER_ERR_INVALID_MNEMONIC) + } + Err(ResolveSeedError::ResolverFailed(_)) + | Err(ResolveSeedError::InvalidMnemonicLength(_)) + | Err(ResolveSeedError::InvalidPassphraseLength(_)) => { + return fail(SIGN_WITH_RESOLVER_ERR_RESOLVER_FAILED) } - _ => return fail(SIGN_WITH_RESOLVER_ERR_RESOLVER_FAILED), - } - if mnemonic_len == 0 || mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { - return fail(SIGN_WITH_RESOLVER_ERR_RESOLVER_FAILED); - } - - // Parse mnemonic. UTF-8 validation runs on the prefix only — - // we never construct a `String` (Swift's String can't be - // zeroized; ours can). - let mnemonic_str = match std::str::from_utf8(&mnemonic_buf[..mnemonic_len]) { - Ok(s) => s, - Err(_) => return fail(SIGN_WITH_RESOLVER_ERR_INVALID_UTF8), - }; - let mnemonic = match parse_mnemonic_any_language(mnemonic_str) { - Ok(m) => m, - Err(_) => return fail(SIGN_WITH_RESOLVER_ERR_INVALID_MNEMONIC), }; - // ---- Derive seed + derivation path -------------------------------------- - let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); - drop(mnemonic); - let path_str = match CStr::from_ptr(derivation_path_cstr).to_str() { Ok(s) => s, Err(_) => return fail(SIGN_WITH_RESOLVER_ERR_INVALID_UTF8), @@ -337,7 +311,12 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_resolver_and_path( #[cfg(test)] mod tests { use super::*; - use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; + use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; + use rs_sdk_ffi::{ + dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy, + mnemonic_resolver_result, + }; + use std::ffi::c_void; use std::ffi::CString; /// English BIP-39 test vector (all-zero entropy). @@ -350,6 +329,9 @@ mod tests { out_buf: *mut c_char, out_capacity: usize, out_len: *mut usize, + _out_passphrase: *mut c_char, + _out_passphrase_capacity: usize, + out_passphrase_len: *mut usize, ) -> i32 { let phrase = ENGLISH_PHRASE.as_bytes(); if phrase.len() + 1 > out_capacity { @@ -358,6 +340,37 @@ mod tests { std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); *out_buf.add(phrase.len()) = 0; *out_len = phrase.len(); + *out_passphrase_len = 0; + mnemonic_resolver_result::SUCCESS + } + + /// Same phrase as [`english_resolve`], with the BIP-39 vector + /// passphrase `TREZOR` — exercises the passphrase leg of the vtable. + unsafe extern "C" fn english_trezor_resolve( + _ctx: *const c_void, + _wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + out_passphrase: *mut c_char, + out_passphrase_capacity: usize, + out_passphrase_len: *mut usize, + ) -> i32 { + let phrase = ENGLISH_PHRASE.as_bytes(); + let passphrase = b"TREZOR"; + if phrase.len() + 1 > out_capacity || passphrase.len() + 1 > out_passphrase_capacity { + return mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + std::ptr::copy_nonoverlapping( + passphrase.as_ptr() as *const c_char, + out_passphrase, + passphrase.len(), + ); + *out_passphrase.add(passphrase.len()) = 0; + *out_passphrase_len = passphrase.len(); mnemonic_resolver_result::SUCCESS } @@ -367,6 +380,9 @@ mod tests { _out_buf: *mut c_char, _out_capacity: usize, _out_len: *mut usize, + _out_passphrase: *mut c_char, + _out_passphrase_capacity: usize, + _out_passphrase_len: *mut usize, ) -> i32 { mnemonic_resolver_result::NOT_FOUND } @@ -574,6 +590,69 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + /// A wallet whose resolver carries a BIP-39 passphrase signs with the + /// passphrase-derived key: the key bound at `expected_key_data` is the + /// one `to_seed("TREZOR")` produces, and the same call bound to the + /// empty-passphrase key is refused. Pins that the passphrase leg of the + /// vtable reaches the signer instead of being dropped on the floor. + #[test] + fn passphrase_resolver_signs_with_the_passphrase_derived_key() { + use key_wallet::bip32::ExtendedPubKey; + + let path_str = "m/9'/1'/5'/0'/0'/0'/0'"; + let path = CString::new(path_str).unwrap(); + let wallet_id = [0u8; 32]; + let data = b"passphrase wallet sign"; + let secp = Secp256k1::new(); + let mnemonic = parse_mnemonic_any_language(ENGLISH_PHRASE).expect("mnemonic"); + let derivation = DerivationPath::from_str(path_str).unwrap(); + + let pubkey_for = |passphrase: &str| -> [u8; 33] { + let seed = mnemonic.to_seed(passphrase); + let master = ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master"); + let derived = master.derive_priv(&secp, &derivation).expect("derive"); + ExtendedPubKey::from_priv(&secp, &derived) + .public_key + .serialize() + }; + let with_passphrase = pubkey_for("TREZOR"); + let without_passphrase = pubkey_for(""); + assert_ne!(with_passphrase, without_passphrase); + + let sign = |expected: &[u8; 33]| -> (i32, u8) { + let resolver = make_resolver(english_trezor_resolve); + let mut sig_buf = [0u8; 128]; + let mut sig_len: usize = 0; + let mut err: u8 = 0; + let rc = unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + 0, + FFINetwork::Testnet, + expected.as_ptr(), + expected.len(), + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len, + &mut err, + ) + }; + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + (rc, err) + }; + + assert_eq!(sign(&with_passphrase), (0, SIGN_WITH_RESOLVER_OK)); + assert_eq!( + sign(&without_passphrase), + (-1, SIGN_WITH_RESOLVER_ERR_PUBKEY_MISMATCH), + "the empty-passphrase key must not bind for a passphrase wallet" + ); + } + /// A `HASH160` identity key (key_type 2) binds by the 20-byte /// `ripemd160_sha256` of the derived pubkey and signs via the same /// secp256k1 path — proving the resolver covers every wallet-derivable diff --git a/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs b/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs index 9db1d6a312a..189ff4b7a9f 100644 --- a/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs +++ b/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs @@ -578,6 +578,7 @@ async fn run() -> Result<(), Box> { let wallet = manager .create_wallet_from_mnemonic( &phrase, + "", Network::Testnet, WalletAccountCreationOptions::Default, Some(0), diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 2fedc74a1a8..65d6b1d6dd8 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -53,6 +53,7 @@ pub use manager::platform_address_sync::{ PlatformAddressSyncManager, PlatformAddressSyncSummary, WalletSyncOutcome, DEFAULT_SYNC_INTERVAL_SECS, }; +pub use manager::seed_from_mnemonic; pub use manager::PlatformWalletManager; pub use spv::SpvRuntime; pub use wallet::asset_lock::manager::AssetLockManager; diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index e95c712c338..9c943762bcc 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -10,6 +10,7 @@ pub mod platform_address_sync; pub mod shielded_sync; pub mod startup; mod wallet_lifecycle; +pub use wallet_lifecycle::seed_from_mnemonic; use std::sync::Arc; use std::time::Duration; diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 2de7cc831fa..1b1a20ad200 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -51,6 +51,21 @@ fn parse_mnemonic_any_language(phrase: &str) -> Result { Err("phrase does not match any supported BIP-39 wordlist") } +/// Derive the 64-byte BIP-39 seed for `(mnemonic_phrase, passphrase)` +/// without registering a wallet. Same language auto-detection and +/// passphrase normalization as +/// [`PlatformWalletManager::create_wallet_from_mnemonic`], so a host can +/// pre-compute the wallet id a create would produce +/// (`Wallet::from_seed_bytes(seed, network, ..).wallet_id`). +pub fn seed_from_mnemonic( + mnemonic_phrase: &str, + passphrase: &str, +) -> Result, PlatformWalletError> { + let mnemonic = parse_mnemonic_any_language(mnemonic_phrase) + .map_err(|e| PlatformWalletError::WalletCreation(format!("Invalid mnemonic: {}", e)))?; + Ok(zeroize::Zeroizing::new(mnemonic.to_seed(passphrase))) +} + /// Test-only rendezvous fired inside [`PlatformWalletManager::remove_wallet_with_teardown`], /// between the inner-manager removal and the public-map removal. /// @@ -78,14 +93,25 @@ pub(crate) static REMOVE_WALLET_MIDPOINT_HOOK: std::sync::Mutex PlatformWalletManager

{ - /// Create a PlatformWallet from a BIP39 mnemonic phrase. + /// Create a PlatformWallet from a BIP39 mnemonic phrase and an + /// optional BIP-39 passphrase (`""` for none). /// /// The mnemonic's language is auto-detected by trying each /// supported BIP-39 wordlist in turn (see - /// [`parse_mnemonic_any_language`]). For passphrase-only flows or - /// out-of-band seed material, derive the seed externally and use + /// [`parse_mnemonic_any_language`]). The seed is + /// `PBKDF2(mnemonic, passphrase)`, so the same phrase with a + /// different passphrase is a different wallet with a different + /// network-scoped id. For out-of-band seed material use /// [`Self::create_wallet_from_seed_bytes`]. /// + /// The wallet is registered as a seed wallet + /// (`key_wallet::WalletType::Seed`) rather than a mnemonic wallet: + /// key-wallet's `Mnemonic` variant hardcodes the empty passphrase + /// (rust-dashcore #747 removed `MnemonicWithPassphrase`), and every + /// consumer in this crate reads key material through + /// `wallet_seed_bytes()` / the root extended key, which both variants + /// serve identically. + /// /// `birth_height_override` controls SPV's compact-filter scan /// window for the new wallet. `None` (the default for fresh /// wallets) resolves the birth height from SPV's current @@ -103,13 +129,17 @@ impl PlatformWalletManager

{ pub async fn create_wallet_from_mnemonic( &self, mnemonic_phrase: &str, + passphrase: &str, network: Network, accounts: WalletAccountCreationOptions, birth_height_override: Option, ) -> Result, PlatformWalletError> { let mnemonic = parse_mnemonic_any_language(mnemonic_phrase) .map_err(|e| PlatformWalletError::WalletCreation(format!("Invalid mnemonic: {}", e)))?; - let wallet = Wallet::from_mnemonic(mnemonic, network, accounts).map_err(|e| { + // `to_seed` NFKD-normalizes the passphrase per BIP-39. + let seed = zeroize::Zeroizing::new(mnemonic.to_seed(passphrase)); + drop(mnemonic); + let wallet = Wallet::from_seed_bytes(*seed, network, accounts).map_err(|e| { PlatformWalletError::WalletCreation(format!( "Failed to create wallet from mnemonic: {}", e @@ -1036,6 +1066,7 @@ mod register_wallet_duplicate_tests { use key_wallet::mnemonic::{Language, Mnemonic}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; use key_wallet::Network; use crate::changeset::{ @@ -1088,6 +1119,61 @@ mod register_wallet_duplicate_tests { Arc::new(PlatformWalletManager::new(sdk, persister, event_handler)) } + /// The mnemonic create path must land on the same network-scoped id as + /// the seed path for the same `(phrase, passphrase)` — that is the + /// contract the iOS host relies on to pre-derive the id before it + /// persists the secret — and a passphrase must move the id. + #[tokio::test] + async fn mnemonic_create_matches_seed_create_and_passphrase_changes_the_id() { + let manager = make_manager(); + let network = Network::Testnet; + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + + let via_mnemonic = manager + .create_wallet_from_mnemonic( + TEST_MNEMONIC, + "TREZOR", + network, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("create with passphrase"); + let expected_seed = mnemonic.to_seed("TREZOR"); + let via_seed_id = Wallet::from_seed_bytes( + expected_seed, + network, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet") + .wallet_id; + assert_eq!(via_mnemonic.wallet_id(), via_seed_id); + assert_eq!( + *super::seed_from_mnemonic(TEST_MNEMONIC, "TREZOR").expect("seed"), + expected_seed + ); + + let without_passphrase = manager + .create_wallet_from_mnemonic( + TEST_MNEMONIC, + "", + network, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("create without passphrase is a distinct wallet"); + assert_ne!(without_passphrase.wallet_id(), via_mnemonic.wallet_id()); + assert_eq!( + without_passphrase.wallet_id(), + Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) + .expect("mnemonic wallet") + .wallet_id, + "the empty passphrase must keep today's ids" + ); + } + /// Registering the SAME wallet (same mnemonic/seed + network) twice /// must surface the typed `WalletAlreadyExists` on the second call — /// NOT `WalletCreation`. This exercises the real producer path diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index d18f1fb5cd4..d750ae30a9c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -228,7 +228,9 @@ impl IdentityWallet { /// material a key-resident wallet would. /// /// `master` must be the BIP-32 master node for this wallet on its - /// network (`ExtendedPrivKey::new_master(network, mnemonic.to_seed(""))`). + /// network (`ExtendedPrivKey::new_master(network, seed)` where `seed` + /// is the wallet's BIP-39 seed, i.e. `mnemonic.to_seed(passphrase)` + /// with the wallet's stored passphrase, or `""` when it has none). pub async fn discover_from_master( &self, opts: IdentityDiscoveryOptions, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index bdbde9b0850..83956ea19ad 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -142,7 +142,9 @@ impl IdentityWallet { /// the key material a key-resident wallet would. /// /// `master` must be the BIP-32 master node for this wallet on its - /// network (`ExtendedPrivKey::new_master(network, mnemonic.to_seed(""))`), + /// network (`ExtendedPrivKey::new_master(network, seed)` where `seed` + /// is the wallet's BIP-39 seed, i.e. `mnemonic.to_seed(passphrase)` + /// with the wallet's stored passphrase, or `""` when it has none), /// same as [`Self::discover_from_master`]. pub async fn load_identity_by_index_from_master( &self, diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver.rs index 817ec322eed..a93ffd21af4 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver.rs @@ -1,4 +1,5 @@ -//! Rust → Swift "fetch BIP-39 mnemonic for wallet_id" FFI vtable. +//! Rust → Swift "fetch BIP-39 mnemonic (+ optional passphrase) for +//! wallet_id" FFI vtable. //! //! The architectural intent is the `swift-sdk/CLAUDE.md` "no //! mnemonic round-tripping" rule: derivation pipelines must live on @@ -35,6 +36,9 @@ use std::ffi::c_void; use std::os::raw::c_char; +use key_wallet::mnemonic::Mnemonic; +use zeroize::Zeroizing; + /// Maximum mnemonic length, in bytes (excluding the trailing NUL), /// the resolver buffer can hold. /// @@ -46,6 +50,15 @@ use std::os::raw::c_char; /// zero-on-drop. pub const MNEMONIC_RESOLVER_BUFFER_CAPACITY: usize = 1024; +/// Maximum BIP-39 passphrase length, in bytes (excluding the trailing +/// NUL), the resolver buffer can hold. +/// +/// BIP-39 places no bound on the passphrase; this is the wire cap the +/// resolver contract imposes so the Rust side can keep the buffer on the +/// stack and zero it on drop. 1024 bytes is far beyond anything a person +/// types or a hardware wallet accepts (Trezor caps at 50 bytes). +pub const PASSPHRASE_RESOLVER_BUFFER_CAPACITY: usize = 1024; + /// Resolver result codes returned by [`MnemonicResolveCallback`]. /// /// Mirrors the success/failure shape of `PlatformWalletFFIResult` @@ -53,16 +66,20 @@ pub const MNEMONIC_RESOLVER_BUFFER_CAPACITY: usize = 1024; /// "Swift hit the buffer cap" from "Swift had no mnemonic stored /// for this wallet". pub mod mnemonic_resolver_result { - /// Mnemonic copied into the buffer; `out_len` was set. + /// Mnemonic copied into the buffer; `out_len` was set. The + /// passphrase buffer holds the wallet's BIP-39 passphrase with + /// `*out_passphrase_len` set — `0` when the wallet has none. pub const SUCCESS: i32 = 0; /// The Swift side has no mnemonic stored for this `wallet_id`. /// Surfaced to the Rust caller as /// `PlatformWalletFFIResult::ErrorWalletOperation` with a /// "mnemonic missing" detail. pub const NOT_FOUND: i32 = 1; - /// Mnemonic exceeded [`super::MNEMONIC_RESOLVER_BUFFER_CAPACITY`]. - /// Should not happen in practice — the buffer is sized for - /// every BIP-39 wordlist's 24-word phrase plus margin. + /// Mnemonic exceeded [`super::MNEMONIC_RESOLVER_BUFFER_CAPACITY`] + /// or the passphrase exceeded + /// [`super::PASSPHRASE_RESOLVER_BUFFER_CAPACITY`]. Should not happen + /// in practice — the buffers are sized for every BIP-39 wordlist's + /// 24-word phrase plus margin, and for any passphrase a person types. pub const BUFFER_TOO_SMALL: i32 = 2; /// Anything else (Keychain access denied, decode error, etc.). /// Surfaced as `ErrorWalletOperation` with a generic detail @@ -89,13 +106,25 @@ pub mod mnemonic_resolver_result { /// - `out_len`: receives the byte count written to /// `out_mnemonic_utf8`, EXCLUDING the trailing NUL. Must be set /// on success. +/// - `out_passphrase_utf8`: writable buffer for the NUL-terminated +/// UTF-8 BIP-39 passphrase ("25th word") of the wallet, if it has +/// one. Capacity is [`PASSPHRASE_RESOLVER_BUFFER_CAPACITY`] bytes. +/// - `out_passphrase_capacity`: equal to +/// [`PASSPHRASE_RESOLVER_BUFFER_CAPACITY`]. +/// - `out_passphrase_len`: receives the byte count written to +/// `out_passphrase_utf8`, EXCLUDING the trailing NUL. Must be set +/// on success — `0` means the wallet has no passphrase, and Rust +/// then derives the seed exactly as it always has (`to_seed("")`). /// /// # Safety /// /// - `out_mnemonic_utf8` must be valid for `out_capacity` writable /// bytes for the duration of this call. +/// - `out_passphrase_utf8` must be valid for `out_passphrase_capacity` +/// writable bytes for the duration of this call. /// - `wallet_id_bytes` must be valid for 32 readable bytes. -/// - `out_len` must be valid for one `usize` write. +/// - `out_len` and `out_passphrase_len` must each be valid for one +/// `usize` write. /// - The implementation MUST return one of the /// [`mnemonic_resolver_result`] codes; any other value is treated /// as `OTHER`. @@ -105,12 +134,15 @@ pub type MnemonicResolveCallback = unsafe extern "C" fn( out_mnemonic_utf8: *mut c_char, out_capacity: usize, out_len: *mut usize, + out_passphrase_utf8: *mut c_char, + out_passphrase_capacity: usize, + out_passphrase_len: *mut usize, ) -> i32; /// C-compatible vtable for a mnemonic resolver. #[repr(C)] pub struct MnemonicResolverVTable { - /// Synchronous "fetch mnemonic for `wallet_id`". + /// Synchronous "fetch mnemonic + passphrase for `wallet_id`". pub resolve: MnemonicResolveCallback, /// Destructor for the `ctx` pointer. Invoked exactly once when /// the matching `dash_sdk_mnemonic_resolver_destroy` is called. @@ -185,3 +217,338 @@ pub unsafe extern "C" fn dash_sdk_mnemonic_resolver_destroy(handle: *mut Mnemoni } } } + +/// Why a resolve failed, as the Rust side of the vtable sees it. +/// +/// Every consumer of the resolver used to hand-roll the result-code +/// match, the length check, the UTF-8 check and the wordlist parse; +/// this enum is the single shape those checks now report through so +/// each FFI entry point only has to map it onto its own error surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolveSeedError { + /// The resolver reported no stored mnemonic for this wallet id + /// ([`mnemonic_resolver_result::NOT_FOUND`]). + NotFound, + /// The mnemonic or the passphrase did not fit its buffer + /// ([`mnemonic_resolver_result::BUFFER_TOO_SMALL`]). + BufferTooSmall, + /// Any other resolver return code (Keychain locked / denied, …). + /// Carries the raw code. + ResolverFailed(i32), + /// The resolver claimed a mnemonic length of zero or beyond the + /// buffer capacity — a framing bug on the host side. + InvalidMnemonicLength(usize), + /// The resolver claimed a passphrase length beyond the buffer + /// capacity — a framing bug on the host side. + InvalidPassphraseLength(usize), + /// Mnemonic or passphrase bytes were not valid UTF-8. + InvalidUtf8, + /// The mnemonic matched no supported BIP-39 wordlist / failed its + /// checksum. + InvalidMnemonic, +} + +impl std::fmt::Display for ResolveSeedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound => { + write!( + f, + "mnemonic resolver: no mnemonic stored for the supplied wallet_id" + ) + } + Self::BufferTooSmall => write!( + f, + "mnemonic resolver: mnemonic or passphrase exceeded the FFI buffer capacity" + ), + Self::ResolverFailed(_) => { + write!( + f, + "mnemonic resolver: failed (other / Keychain access error)" + ) + } + Self::InvalidMnemonicLength(_) | Self::InvalidPassphraseLength(_) => { + write!(f, "mnemonic resolver: returned invalid length") + } + Self::InvalidUtf8 => write!(f, "mnemonic resolver: returned invalid UTF-8"), + Self::InvalidMnemonic => { + write!(f, "mnemonic resolver: returned an invalid mnemonic") + } + } + } +} + +/// Fire the resolver once for `wallet_id` and return the wallet's +/// 64-byte BIP-39 seed — `PBKDF2(mnemonic, passphrase)` with the +/// passphrase the host stored for this wallet (empty when it has none). +/// +/// This is the ONE place the resolver vtable is consumed. Every FFI +/// entry point that derives from a Keychain-resident wallet routes +/// through here, so the passphrase cannot be dropped on one path and +/// honoured on another. Both host-written buffers and the mnemonic are +/// held in [`Zeroizing`] storage and scrubbed before this returns; the +/// returned seed is scrubbed when the caller drops it. +/// +/// # Safety +/// `handle` must be non-null, come from +/// [`dash_sdk_mnemonic_resolver_create`], and stay valid for the +/// duration of the call. +pub unsafe fn resolve_seed( + handle: *const MnemonicResolverHandle, + wallet_id: &[u8; 32], +) -> Result, ResolveSeedError> { + let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = + Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); + let mut mnemonic_len: usize = 0; + let mut passphrase_buf: Zeroizing<[u8; PASSPHRASE_RESOLVER_BUFFER_CAPACITY]> = + Zeroizing::new([0u8; PASSPHRASE_RESOLVER_BUFFER_CAPACITY]); + let mut passphrase_len: usize = 0; + + let resolver = &*handle; + let vtable = &*resolver.vtable; + let rc = (vtable.resolve)( + resolver.ctx as *const c_void, + wallet_id.as_ptr(), + mnemonic_buf.as_mut_ptr() as *mut c_char, + MNEMONIC_RESOLVER_BUFFER_CAPACITY, + &mut mnemonic_len, + passphrase_buf.as_mut_ptr() as *mut c_char, + PASSPHRASE_RESOLVER_BUFFER_CAPACITY, + &mut passphrase_len, + ); + match rc { + x if x == mnemonic_resolver_result::SUCCESS => {} + x if x == mnemonic_resolver_result::NOT_FOUND => return Err(ResolveSeedError::NotFound), + x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { + return Err(ResolveSeedError::BufferTooSmall) + } + other => return Err(ResolveSeedError::ResolverFailed(other)), + } + if mnemonic_len == 0 || mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { + return Err(ResolveSeedError::InvalidMnemonicLength(mnemonic_len)); + } + if passphrase_len > PASSPHRASE_RESOLVER_BUFFER_CAPACITY { + return Err(ResolveSeedError::InvalidPassphraseLength(passphrase_len)); + } + + // UTF-8 validation runs over the claimed prefixes only — no owned + // `String` is ever built (the buffers are dropped via `Zeroizing`). + let mnemonic_str = std::str::from_utf8(&mnemonic_buf[..mnemonic_len]) + .map_err(|_| ResolveSeedError::InvalidUtf8)?; + let passphrase_str = std::str::from_utf8(&passphrase_buf[..passphrase_len]) + .map_err(|_| ResolveSeedError::InvalidUtf8)?; + let mnemonic = Mnemonic::from_phrase_in_any_language(mnemonic_str) + .map_err(|_| ResolveSeedError::InvalidMnemonic)?; + + // `to_seed` NFKD-normalizes the passphrase itself (BIP-39 §"From + // mnemonic to seed"), so the host may pass it exactly as typed. + let seed = Zeroizing::new(mnemonic.to_seed(passphrase_str)); + drop(mnemonic); + Ok(seed) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// English BIP-39 test vector (all-zero entropy). + const ENGLISH_PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + /// Seed of `ENGLISH_PHRASE` with the empty passphrase (bip39 vectors). + const SEED_NO_PASSPHRASE: &str = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"; + /// Seed of `ENGLISH_PHRASE` with the passphrase `TREZOR` (bip39 vectors). + const SEED_TREZOR: &str = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + /// Copies `phrase` and `passphrase` into the resolver's out buffers + /// with the wire contract this module documents. + #[allow(clippy::too_many_arguments)] + unsafe fn fill( + phrase: &str, + passphrase: &str, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + out_pp: *mut c_char, + out_pp_capacity: usize, + out_pp_len: *mut usize, + ) -> i32 { + let phrase = phrase.as_bytes(); + let passphrase = passphrase.as_bytes(); + if phrase.len() + 1 > out_capacity || passphrase.len() + 1 > out_pp_capacity { + return mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + std::ptr::copy_nonoverlapping( + passphrase.as_ptr() as *const c_char, + out_pp, + passphrase.len(), + ); + *out_pp.add(passphrase.len()) = 0; + *out_pp_len = passphrase.len(); + mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn resolve_no_passphrase( + _ctx: *const c_void, + _wallet_id: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + out_pp: *mut c_char, + out_pp_capacity: usize, + out_pp_len: *mut usize, + ) -> i32 { + fill( + ENGLISH_PHRASE, + "", + out_buf, + out_capacity, + out_len, + out_pp, + out_pp_capacity, + out_pp_len, + ) + } + + unsafe extern "C" fn resolve_trezor_passphrase( + _ctx: *const c_void, + _wallet_id: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + out_pp: *mut c_char, + out_pp_capacity: usize, + out_pp_len: *mut usize, + ) -> i32 { + fill( + ENGLISH_PHRASE, + "TREZOR", + out_buf, + out_capacity, + out_len, + out_pp, + out_pp_capacity, + out_pp_len, + ) + } + + /// A host that leaves the passphrase buffer untouched but still + /// reports `len = 0` — the shape a "no passphrase" wallet produces. + unsafe extern "C" fn resolve_passphrase_len_untouched_buffer( + _ctx: *const c_void, + _wallet_id: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + _out_pp: *mut c_char, + _out_pp_capacity: usize, + out_pp_len: *mut usize, + ) -> i32 { + let phrase = ENGLISH_PHRASE.as_bytes(); + assert!(phrase.len() < out_capacity); + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + *out_pp_len = 0; + mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn resolve_not_found( + _ctx: *const c_void, + _wallet_id: *const u8, + _out_buf: *mut c_char, + _out_capacity: usize, + _out_len: *mut usize, + _out_pp: *mut c_char, + _out_pp_capacity: usize, + _out_pp_len: *mut usize, + ) -> i32 { + mnemonic_resolver_result::NOT_FOUND + } + + unsafe extern "C" fn resolve_absurd_passphrase_len( + _ctx: *const c_void, + _wallet_id: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + _out_pp: *mut c_char, + _out_pp_capacity: usize, + out_pp_len: *mut usize, + ) -> i32 { + let phrase = ENGLISH_PHRASE.as_bytes(); + assert!(phrase.len() < out_capacity); + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_len = phrase.len(); + *out_pp_len = PASSPHRASE_RESOLVER_BUFFER_CAPACITY + 1; + mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + + fn with_resolver( + cb: MnemonicResolveCallback, + body: impl FnOnce(*mut MnemonicResolverHandle) -> R, + ) -> R { + let handle = + unsafe { dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), cb, noop_destroy) }; + let out = body(handle); + unsafe { dash_sdk_mnemonic_resolver_destroy(handle) }; + out + } + + #[test] + fn empty_passphrase_yields_the_bip39_vector_seed() { + let seed = with_resolver(resolve_no_passphrase, |h| unsafe { + resolve_seed(h, &[0u8; 32]) + }) + .expect("resolves"); + assert_eq!(hex(seed.as_ref()), SEED_NO_PASSPHRASE); + } + + #[test] + fn passphrase_changes_the_seed_to_the_bip39_vector() { + let seed = with_resolver(resolve_trezor_passphrase, |h| unsafe { + resolve_seed(h, &[0u8; 32]) + }) + .expect("resolves"); + assert_eq!(hex(seed.as_ref()), SEED_TREZOR); + } + + #[test] + fn zero_passphrase_length_means_no_passphrase_regardless_of_buffer_contents() { + let seed = with_resolver(resolve_passphrase_len_untouched_buffer, |h| unsafe { + resolve_seed(h, &[0u8; 32]) + }) + .expect("resolves"); + assert_eq!(hex(seed.as_ref()), SEED_NO_PASSPHRASE); + } + + #[test] + fn not_found_is_reported_as_such() { + let err = with_resolver(resolve_not_found, |h| unsafe { + resolve_seed(h, &[0u8; 32]) + }) + .expect_err("must fail"); + assert_eq!(err, ResolveSeedError::NotFound); + } + + #[test] + fn an_impossible_passphrase_length_is_rejected() { + let err = with_resolver(resolve_absurd_passphrase_len, |h| unsafe { + resolve_seed(h, &[0u8; 32]) + }) + .expect_err("must fail"); + assert_eq!( + err, + ResolveSeedError::InvalidPassphraseLength(PASSPHRASE_RESOLVER_BUFFER_CAPACITY + 1) + ); + } +} diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index b0c1a3c4d3c..33fe6b5d80e 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -62,9 +62,6 @@ //! Combined, no private key bytes survive past the trait-method //! boundary. -use std::ffi::c_void; -use std::os::raw::c_char; - use async_trait::async_trait; use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::secp256k1::{self, Secp256k1}; @@ -78,10 +75,7 @@ use key_wallet::Network; use thiserror::Error; use zeroize::Zeroizing; -use crate::mnemonic_resolver::{ - mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, -}; -use crate::signer_simple::parse_mnemonic_any_language; +use crate::mnemonic_resolver::{resolve_seed, MnemonicResolverHandle, ResolveSeedError}; /// Failure modes for the /// [`MnemonicResolverCoreSigner`](crate::mnemonic_resolver_core_signer::MnemonicResolverCoreSigner) @@ -149,6 +143,11 @@ pub enum MnemonicResolverSignerError { #[error("resolver returned invalid mnemonic length {0}")] InvalidMnemonicLength(usize), + /// The resolver declared a passphrase length beyond the buffer + /// capacity. Indicates a Swift-side framing bug. + #[error("resolver returned invalid passphrase length {0}")] + InvalidPassphraseLength(usize), + /// The resolved string is not a valid BIP-39 mnemonic phrase /// (failed checksum or word-list lookup). #[error("invalid mnemonic phrase: {0}")] @@ -169,6 +168,22 @@ pub enum MnemonicResolverSignerError { InvalidScalar(String), } +impl From for MnemonicResolverSignerError { + fn from(e: ResolveSeedError) -> Self { + match e { + ResolveSeedError::NotFound => Self::NotFound, + ResolveSeedError::BufferTooSmall => Self::BufferTooSmall, + ResolveSeedError::ResolverFailed(code) => Self::ResolverFailed(code), + ResolveSeedError::InvalidMnemonicLength(len) => Self::InvalidMnemonicLength(len), + ResolveSeedError::InvalidPassphraseLength(len) => Self::InvalidPassphraseLength(len), + ResolveSeedError::InvalidUtf8 => Self::InvalidUtf8, + ResolveSeedError::InvalidMnemonic => { + Self::InvalidMnemonic("phrase does not match any supported BIP-39 wordlist".into()) + } + } + } +} + /// `key_wallet::signer::Signer` implementation that derives ECDSA /// secp256k1 keys from a wallet mnemonic, fetched via a Swift-owned /// [`MnemonicResolverHandle`]. @@ -249,7 +264,9 @@ impl MnemonicResolverCoreSigner { /// /// This is the single entry-point for all private-key material in this /// signer. It handles the full stack: resolver FFI call → result-code - /// mapping → UTF-8 + word-list validation → BIP-39 seed → master + /// mapping → UTF-8 + word-list validation → BIP-39 seed (with the + /// wallet's stored passphrase, via + /// [`crate::mnemonic_resolver::resolve_seed`]) → master /// `ExtendedPrivKey` → child `ExtendedPrivKey` at `path`. /// /// # Zeroization contract @@ -278,57 +295,21 @@ impl MnemonicResolverCoreSigner { return Err(MnemonicResolverSignerError::NullHandle); } - // ---- Resolve mnemonic into a Zeroizing buffer ----------------------- - let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = - Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); - let mut mnemonic_len: usize = 0; - - // SAFETY: We re-cast from `usize` to `*mut MnemonicResolverHandle` + // ---- Resolve seed via the shared vtable consumer ------------------- + // SAFETY: We re-cast from `usize` to `*const MnemonicResolverHandle` // here. The caller of `new()` guaranteed the original pointer // outlives this signer (see the unsafety contract on // `Self::new`). `MnemonicResolverHandle`'s vtable + ctx are // thread-stable per the same module's `unsafe impl Send + - // Sync` justification. - let resolver = unsafe { &*(self.resolver_addr as *const MnemonicResolverHandle) }; - let vtable = unsafe { &*resolver.vtable }; - let rc = unsafe { - (vtable.resolve)( - resolver.ctx as *const c_void, - self.wallet_id.as_ptr(), - mnemonic_buf.as_mut_ptr() as *mut c_char, - MNEMONIC_RESOLVER_BUFFER_CAPACITY, - &mut mnemonic_len, - ) + // Sync` justification. `resolve_seed` folds in the wallet's + // stored BIP-39 passphrase, so a passphrase wallet signs with + // the same keys it was created with. + let seed: Zeroizing<[u8; 64]> = unsafe { + resolve_seed( + self.resolver_addr as *const MnemonicResolverHandle, + &self.wallet_id, + )? }; - match rc { - x if x == mnemonic_resolver_result::SUCCESS => {} - x if x == mnemonic_resolver_result::NOT_FOUND => { - return Err(MnemonicResolverSignerError::NotFound); - } - x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { - return Err(MnemonicResolverSignerError::BufferTooSmall); - } - other => { - return Err(MnemonicResolverSignerError::ResolverFailed(other)); - } - } - if mnemonic_len == 0 || mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { - return Err(MnemonicResolverSignerError::InvalidMnemonicLength( - mnemonic_len, - )); - } - - // Parse mnemonic. UTF-8 validation runs on the prefix only — - // we never construct an owned `String` (the resulting buffer - // is dropped via Zeroizing). - let mnemonic_str = std::str::from_utf8(&mnemonic_buf[..mnemonic_len]) - .map_err(|_| MnemonicResolverSignerError::InvalidUtf8)?; - let mnemonic = parse_mnemonic_any_language(mnemonic_str) - .map_err(|e| MnemonicResolverSignerError::InvalidMnemonic(e.to_string()))?; - - // ---- Derive seed and BIP-32 key at `path` --------------------------- - let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); - drop(mnemonic); let secp = Secp256k1::new(); let master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) @@ -690,9 +671,14 @@ impl ExtendedPubKeySigner for MnemonicResolverCoreSigner { #[cfg(test)] mod tests { use super::*; + use std::ffi::c_void; + use std::os::raw::c_char; + use crate::mnemonic_resolver::{ dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy, + mnemonic_resolver_result, }; + use crate::signer_simple::parse_mnemonic_any_language; use std::str::FromStr; /// English BIP-39 test vector (all-zero entropy). @@ -705,6 +691,9 @@ mod tests { out_buf: *mut c_char, out_capacity: usize, out_len: *mut usize, + _out_passphrase: *mut c_char, + _out_passphrase_capacity: usize, + out_passphrase_len: *mut usize, ) -> i32 { let phrase = ENGLISH_PHRASE.as_bytes(); if phrase.len() + 1 > out_capacity { @@ -713,6 +702,7 @@ mod tests { std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); *out_buf.add(phrase.len()) = 0; *out_len = phrase.len(); + *out_passphrase_len = 0; mnemonic_resolver_result::SUCCESS } @@ -722,6 +712,9 @@ mod tests { _out_buf: *mut c_char, _out_capacity: usize, _out_len: *mut usize, + _out_passphrase: *mut c_char, + _out_passphrase_capacity: usize, + _out_passphrase_len: *mut usize, ) -> i32 { mnemonic_resolver_result::NOT_FOUND } diff --git a/packages/rs-unified-sdk-jni/src/mnemonic.rs b/packages/rs-unified-sdk-jni/src/mnemonic.rs index 9459373d0e6..858e82b1383 100644 --- a/packages/rs-unified-sdk-jni/src/mnemonic.rs +++ b/packages/rs-unified-sdk-jni/src/mnemonic.rs @@ -48,11 +48,22 @@ unsafe extern "C" fn resolve_trampoline( out_mnemonic_utf8: *mut c_char, out_capacity: usize, out_len: *mut usize, + _out_passphrase_utf8: *mut c_char, + _out_passphrase_capacity: usize, + out_passphrase_len: *mut usize, ) -> i32 { let result = catch_unwind(AssertUnwindSafe(|| { - if ctx.is_null() || wallet_id_bytes.is_null() || out_mnemonic_utf8.is_null() { + if ctx.is_null() + || wallet_id_bytes.is_null() + || out_mnemonic_utf8.is_null() + || out_passphrase_len.is_null() + { return RESULT_OTHER; } + // TODO(seed-passphrase): the Kotlin `NativeMnemonicBridge` has no + // passphrase slot yet, so Android wallets always resolve with the + // empty passphrase (unchanged behaviour). + *out_passphrase_len = 0; let ctx = &*(ctx as *const KotlinMnemonicCtx); let Some(vm) = JVM.get() else { return RESULT_OTHER; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift index e468cc44406..d2150b6d9b5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift @@ -19,6 +19,10 @@ import Security /// /// * Per-wallet mnemonic storage at /// `wallet.mnemonic.<64-char-hex-walletId>`. +/// * Per-wallet optional BIP-39 passphrase ("25th word") at +/// `wallet.passphrase.<64-char-hex-walletId>`. Only present for +/// wallets created with one; the resolver hands it to Rust next +/// to the mnemonic so the seed is `PBKDF2(mnemonic, passphrase)`. /// * Per-wallet user-facing metadata (display name + free-form /// description) at `wallet.metadata.<64-char-hex-walletId>`, /// carried as a JSON-encoded `WalletKeychainMetadata` blob so the @@ -55,6 +59,11 @@ public class WalletStorage { /// user-facing wallet name and description from the keychain /// even though SwiftData was wiped. public static let metadataAccountPrefix = "wallet.metadata" + /// Base account string used to build per-wallet BIP-39 passphrase + /// accounts via `perWalletPassphraseAccount(for:)`. Absent for + /// wallets without a passphrase — `hasPassphrase(for:)` is the + /// discriminator, never an empty item. + public static let passphraseAccountPrefix = "wallet.passphrase" private let biometricKeychainAccount = "wallet.biometric" public init() {} @@ -72,9 +81,15 @@ public class WalletStorage { /// Store a mnemonic keyed by wallet id. public func storeMnemonic(_ mnemonic: String, for walletId: Data) throws { - let data = Data(mnemonic.utf8) - let account = perWalletMnemonicAccount(for: walletId) + try replaceSecret(Data(mnemonic.utf8), account: perWalletMnemonicAccount(for: walletId)) + } + /// Delete-then-add write of a secret at `account` with the + /// `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` class. Shared by the + /// mnemonic and passphrase writers so both items carry the same + /// protection and the same identity-stamp semantics (fresh dates on + /// every write). + private func replaceSecret(_ data: Data, account: String) throws { let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, @@ -197,8 +212,35 @@ public class WalletStorage { /// /// Like `hasMnemonic`, this queries attributes only — the secret is /// never materialized. + /// + /// The passphrase item (if any) is folded into the stamp too: the seed + /// is a function of both secrets, so adding, replacing, or removing the + /// passphrase must invalidate the cached verification exactly like a + /// rewritten mnemonic does. A wallet without a passphrase gets the same + /// stamp it always had. public func mnemonicKeychainStamp(for walletId: Data) -> String? { - let account = perWalletMnemonicAccount(for: walletId) + guard let mnemonicStamp = itemStamp(account: perWalletMnemonicAccount(for: walletId)) else { + return nil + } + switch passphraseAvailability(for: walletId) { + case .absent: + return mnemonicStamp + case .present: + guard let passphraseStamp = itemStamp(account: perWalletPassphraseAccount(for: walletId)) else { + return nil + } + return mnemonicStamp + "-p" + passphraseStamp + case .unavailable: + // Cannot tell whether the seed has a passphrase leg: disable the + // cache for this launch rather than risk coasting on a marker + // verified against the other seed. + return nil + } + } + + /// Attribute-only creation + modification stamp of one Keychain item, or + /// `nil` when the item is missing or its attributes are unreadable. + private func itemStamp(account: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, @@ -221,8 +263,16 @@ public class WalletStorage { } /// Delete a mnemonic keyed by wallet id. Idempotent. + /// + /// Deletes only the mnemonic item; a passphrase stored for the same + /// wallet is a separate item and has its own `deletePassphrase(for:)`. + /// Callers tearing down a wallet delete both (see + /// `PlatformWalletManager.deleteWallet`). public func deleteMnemonic(for walletId: Data) throws { - let account = perWalletMnemonicAccount(for: walletId) + try deleteSecret(account: perWalletMnemonicAccount(for: walletId)) + } + + private func deleteSecret(account: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, @@ -234,6 +284,92 @@ public class WalletStorage { } } + // MARK: - Per-Wallet BIP-39 Passphrase Storage + // + // Optional "25th word". Stored as its own item next to the mnemonic + // rather than inside it so the mnemonic item keeps its plain-phrase + // contract (every reader, including the legacy migrator's + // round-trip check, compares it verbatim). Same accessibility class + // as the mnemonic: the Keychain is the security boundary for both. + + private func perWalletPassphraseAccount(for walletId: Data) -> String { + let hex = walletId.map { String(format: "%02x", $0) }.joined() + return "\(Self.passphraseAccountPrefix).\(hex)" + } + + /// Store the BIP-39 passphrase for `walletId`. An empty passphrase is + /// rejected: "no passphrase" is modelled as the absence of the item, + /// never as an empty one, so `hasPassphrase(for:)` stays a truthful + /// discriminator. + public func storePassphrase(_ passphrase: String, for walletId: Data) throws { + guard !passphrase.isEmpty else { + throw WalletStorageError.emptyPassphrase + } + try replaceSecret(Data(passphrase.utf8), account: perWalletPassphraseAccount(for: walletId)) + } + + /// Retrieve the passphrase UTF-8 bytes for `walletId`. Throws + /// `passphraseNotFound` when the wallet has none — callers that only + /// need to know whether one exists should use `hasPassphrase(for:)`. + public func retrievePassphraseUTF8Bytes(for walletId: Data) throws -> Data { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: perWalletPassphraseAccount(for: walletId), + kSecReturnData as String: true + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + throw WalletStorageError.passphraseNotFound + } + guard status == errSecSuccess else { + throw WalletStorageError.keychainError(status) + } + guard let data = result as? Data, !data.isEmpty else { + throw WalletStorageError.passphraseNotFound + } + return data + } + + /// Retrieve the passphrase for `walletId` as a `String`. + public func retrievePassphrase(for walletId: Data) throws -> String { + let data = try retrievePassphraseUTF8Bytes(for: walletId) + guard let passphrase = String(data: data, encoding: .utf8), !passphrase.isEmpty else { + throw WalletStorageError.passphraseNotFound + } + return passphrase + } + + /// Whether the wallet's passphrase item is readable, keeping "no such + /// item" apart from "could not tell". Attribute-only. + public func passphraseAvailability(for walletId: Data) -> MnemonicAvailability { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: perWalletPassphraseAccount(for: walletId), + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true + ] + var result: AnyObject? + switch SecItemCopyMatching(query as CFDictionary, &result) { + case errSecSuccess: return .present + case errSecItemNotFound: return .absent + case let status: return .unavailable(status) + } + } + + /// Cheap existence check: `true` only when a passphrase item is present. + /// Answers `false` both for "no such item" and for "could not tell". + public func hasPassphrase(for walletId: Data) -> Bool { + passphraseAvailability(for: walletId) == .present + } + + /// Delete the passphrase keyed by wallet id. Idempotent. + public func deletePassphrase(for walletId: Data) throws { + try deleteSecret(account: perWalletPassphraseAccount(for: walletId)) + } + /// Enumerate all wallet ids with a stored mnemonic. /// /// Reads every `kSecClassGenericPassword` entry under @@ -574,6 +710,8 @@ public struct WalletKeychainMetadata: Codable, Equatable { public enum WalletStorageError: LocalizedError { case keychainError(OSStatus) case mnemonicNotFound + case passphraseNotFound + case emptyPassphrase case biometricSetupFailed case biometricAuthenticationFailed @@ -583,6 +721,10 @@ public enum WalletStorageError: LocalizedError { return "Keychain error: \(status)" case .mnemonicNotFound: return "Mnemonic not found" + case .passphraseNotFound: + return "Passphrase not found" + case .emptyPassphrase: + return "Passphrase must not be empty; delete it instead" case .biometricSetupFailed: return "Failed to setup biometric protection" case .biometricAuthenticationFailed: diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift index 2e292c07366..4421bad6ec9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift @@ -11,7 +11,8 @@ private func scrubBytes(_ bytes: inout [UInt8]) { } } -/// Best-effort in-memory obfuscation for mnemonic UTF-8 bytes while +/// Best-effort in-memory obfuscation for secret UTF-8 bytes (the +/// mnemonic, and the BIP-39 passphrase when the wallet has one) while /// they sit on the Swift heap between the Keychain read and the final /// copy into Rust's `Zeroizing` buffer. private final class MaskedMnemonicUTF8 { @@ -66,12 +67,16 @@ private final class MaskedMnemonicUTF8 { /// `dash_sdk_derive_and_persist_identity_keys` (and the /// platform-address signing path in /// `dash_sdk_sign_with_mnemonic_resolver_and_path`) calls back -/// into Swift via this resolver to fetch the BIP-39 mnemonic for -/// the wallet whose identity keys it's deriving. The mnemonic is -/// copied directly into a Rust-owned `Zeroizing` stack buffer; it -/// never round-trips back to Swift after this single read. On the -/// Swift side the bytes are masked while idle, then deobfuscated only -/// long enough to copy into the FFI output buffer. +/// into Swift via this resolver to fetch the BIP-39 mnemonic — and +/// the wallet's BIP-39 passphrase, if it has one — for the wallet +/// whose identity keys it's deriving. Both are copied directly into +/// Rust-owned `Zeroizing` stack buffers; neither round-trips back to +/// Swift after this single read. On the Swift side the bytes are +/// masked while idle, then deobfuscated only long enough to copy into +/// the FFI output buffers. Rust derives the seed as +/// `PBKDF2(mnemonic, passphrase)`, so a passphrase wallet signs with +/// the keys it was created with; a wallet without one reports a +/// zero-length passphrase and derives exactly as before. /// /// # Lifetime contract /// @@ -140,7 +145,10 @@ public final class MnemonicResolver: @unchecked Sendable { walletId: Data, outBuffer: UnsafeMutablePointer, outCapacity: UInt, - outLen: UnsafeMutablePointer + outLen: UnsafeMutablePointer, + outPassphraseBuffer: UnsafeMutablePointer, + outPassphraseCapacity: UInt, + outPassphraseLen: UnsafeMutablePointer ) -> MnemonicResolverResult { // Secret-free audit line: every mnemonic pull through a resolver // handle is observable, so "the launch path never touches the @@ -175,35 +183,77 @@ public final class MnemonicResolver: @unchecked Sendable { return .other } + // The passphrase leg. Absence is the common case and must not turn + // into a failure; only a Keychain that cannot say whether one exists + // is an error (a passphrase wallet resolved without its passphrase + // would derive the wrong keys, so fail closed). + let passphraseUTF8Bytes: Data? + switch storage.passphraseAvailability(for: walletId) { + case .absent: + passphraseUTF8Bytes = nil + case .unavailable: + return .other + case .present: + do { + passphraseUTF8Bytes = try storage.retrievePassphraseUTF8Bytes(for: walletId) + } catch { + return .other + } + } + let maskedMnemonic: MaskedMnemonicUTF8 + let maskedPassphrase: MaskedMnemonicUTF8? do { maskedMnemonic = try MaskedMnemonicUTF8(plaintextUTF8Bytes: mnemonicUTF8Bytes) + maskedPassphrase = try passphraseUTF8Bytes.map { try MaskedMnemonicUTF8(plaintextUTF8Bytes: $0) } } catch { return .other } - return maskedMnemonic.withDeobfuscatedBytes { bytes -> MnemonicResolverResult in - let mnemonicLen = bytes.count - // Need room for the data plus a trailing NUL byte. - guard UInt(mnemonicLen) + 1 <= outCapacity else { - return .bufferTooSmall - } - guard let srcBase = bytes.baseAddress else { - return .other - } - if bytes.contains(0) { - return .other - } - srcBase.withMemoryRebound(to: CChar.self, capacity: mnemonicLen) { srcPtr in - outBuffer.update(from: srcPtr, count: mnemonicLen) - } - // Explicit NUL terminator — defensive, the Rust side - // works off `out_len` not strlen but matching the - // wire contract is cheap insurance. - (outBuffer + mnemonicLen).pointee = 0 - outLen.pointee = UInt(mnemonicLen) + let mnemonicResult = maskedMnemonic.withDeobfuscatedBytes { bytes in + Self.copyNULTerminated(bytes, into: outBuffer, capacity: outCapacity, outLen: outLen) + } + guard mnemonicResult == .success else { return mnemonicResult } + + guard let maskedPassphrase else { + outPassphraseLen.pointee = 0 return .success } + return maskedPassphrase.withDeobfuscatedBytes { bytes in + Self.copyNULTerminated( + bytes, into: outPassphraseBuffer, capacity: outPassphraseCapacity, outLen: outPassphraseLen) + } + } + + /// Copy `bytes` into a Rust-owned out buffer with the resolver wire + /// contract: NUL-terminated, `outLen` excludes the NUL, embedded NULs + /// are refused (they would truncate the C string on the far side). + private static func copyNULTerminated( + _ bytes: UnsafeBufferPointer, + into outBuffer: UnsafeMutablePointer, + capacity: UInt, + outLen: UnsafeMutablePointer + ) -> MnemonicResolverResult { + let len = bytes.count + // Need room for the data plus a trailing NUL byte. + guard UInt(len) + 1 <= capacity else { + return .bufferTooSmall + } + guard let srcBase = bytes.baseAddress else { + return .other + } + if bytes.contains(0) { + return .other + } + srcBase.withMemoryRebound(to: CChar.self, capacity: len) { srcPtr in + outBuffer.update(from: srcPtr, count: len) + } + // Explicit NUL terminator — defensive, the Rust side works off + // `out_len` not strlen but matching the wire contract is cheap + // insurance. + (outBuffer + len).pointee = 0 + outLen.pointee = UInt(len) + return .success } } @@ -214,9 +264,13 @@ private func mnemonicResolverResolveTrampoline( walletIdBytes: UnsafePointer?, outBuffer: UnsafeMutablePointer?, outCapacity: UInt, - outLen: UnsafeMutablePointer? + outLen: UnsafeMutablePointer?, + outPassphraseBuffer: UnsafeMutablePointer?, + outPassphraseCapacity: UInt, + outPassphraseLen: UnsafeMutablePointer? ) -> Int32 { - guard let ctx, let walletIdBytes, let outBuffer, let outLen else { + guard let ctx, let walletIdBytes, let outBuffer, let outLen, + let outPassphraseBuffer, let outPassphraseLen else { return MnemonicResolverResult.other.rawValue } let resolver = Unmanaged.fromOpaque(ctx).takeUnretainedValue() @@ -225,7 +279,10 @@ private func mnemonicResolverResolveTrampoline( walletId: walletId, outBuffer: outBuffer, outCapacity: outCapacity, - outLen: outLen + outLen: outLen, + outPassphraseBuffer: outPassphraseBuffer, + outPassphraseCapacity: outPassphraseCapacity, + outPassphraseLen: outPassphraseLen ) return result.rawValue } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift index 5da61f07d09..a2980237508 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift @@ -60,11 +60,15 @@ public class Mnemonic { return isValid } - /// Convert mnemonic to seed + /// Convert mnemonic to its 64-byte BIP-39 seed. + /// + /// Language is auto-detected across every supported wordlist. The + /// passphrase is NFKD-normalized by the Rust side per BIP-39, so pass it + /// exactly as the user typed it; `nil` / empty means no passphrase. /// - Parameters: /// - mnemonic: The mnemonic phrase - /// - passphrase: Optional BIP39 passphrase - /// - Returns: The seed data (typically 64 bytes) + /// - passphrase: Optional BIP39 passphrase ("25th word") + /// - Returns: The 64-byte seed public static func toSeed(mnemonic: String, passphrase: String? = nil) throws -> Data { try toSeed(mnemonicUTF8Bytes: Data(mnemonic.utf8), passphrase: passphrase) } @@ -76,54 +80,40 @@ public class Mnemonic { throw KeyWalletError.invalidInput("Mnemonic must not be empty") } - var error = FFIError() - var seed = Data(count: 64) - var seedLen: size_t = 64 + var seed = [UInt8](repeating: 0, count: 64) var mnemonicBytes = [UInt8](mnemonicUTF8Bytes) guard !mnemonicBytes.contains(0) else { scrubMnemonicBytes(&mnemonicBytes) throw KeyWalletError.invalidInput("Mnemonic bytes must not contain NUL") } mnemonicBytes.append(0) + defer { scrubMnemonicBytes(&mnemonicBytes) } - let success = mnemonicBytes.withUnsafeBufferPointer { mnemonicBuf in + let result: PlatformWalletFFIResult = mnemonicBytes.withUnsafeBufferPointer { mnemonicBuf in guard let mnemonicBase = mnemonicBuf.baseAddress else { - return false + return PlatformWalletFFIResult( + code: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_NULL_POINTER, message: nil) } return mnemonicBase.withMemoryRebound(to: CChar.self, capacity: mnemonicBuf.count) { mnemonicCStr in - seed.withUnsafeMutableBytes { seedBytes in - let seedPtr = seedBytes.bindMemory(to: UInt8.self).baseAddress - - if let passphrase = passphrase { + seed.withUnsafeMutableBufferPointer { seedBuf -> PlatformWalletFFIResult in + let seedPtr = seedBuf.baseAddress + let seedLen = UInt(seedBuf.count) + if let passphrase, !passphrase.isEmpty { return passphrase.withCString { passphraseCStr in - mnemonic_to_seed(mnemonicCStr, passphraseCStr, - seedPtr, &seedLen, &error) + platform_wallet_mnemonic_to_seed(mnemonicCStr, passphraseCStr, seedPtr, seedLen) } - } else { - return mnemonic_to_seed(mnemonicCStr, nil, - seedPtr, &seedLen, &error) } + return platform_wallet_mnemonic_to_seed(mnemonicCStr, nil, seedPtr, seedLen) } } } - defer { - scrubMnemonicBytes(&mnemonicBytes) - if error.message != nil { - error_message_free(error.message) - } + do { + try result.check() + } catch { + throw KeyWalletError.invalidInput("mnemonic to seed failed: \(error.localizedDescription)") } - - guard success else { - throw KeyWalletError(ffiError: error) - } - - // Resize if necessary - if seedLen < 64 { - seed = seed.prefix(seedLen) - } - - return seed + return Data(seed) } /// Get word count from a mnemonic phrase diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/README.md index 149218dfe78..51e015a695a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/README.md @@ -52,10 +52,14 @@ let mnemonic = try Mnemonic.generate(wordCount: 24) // Create wallet from mnemonic let wallet = try Wallet( mnemonic: mnemonic, - passphrase: nil, network: .testnet ) +// With a BIP39 passphrase ("25th word"): derive the seed, then build +// the wallet from it. +let seed = try Mnemonic.toSeed(mnemonic: mnemonic, passphrase: "my-secret-passphrase") +let passphraseWallet = try Wallet(seed: seed, network: .testnet) + // Get wallet ID let walletId = try wallet.id print("Wallet ID: \(walletId.toHexString())") diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift index 540cf0adb8c..0c86dccc722 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift @@ -55,10 +55,12 @@ public class WalletManager { // MARK: - Wallet Management - /// Add a wallet from mnemonic + /// Add a wallet from mnemonic (empty BIP39 passphrase). + /// + /// For a passphrase wallet derive the seed with + /// `Mnemonic.toSeed(mnemonic:passphrase:)` and use `Wallet(seed:)`. /// - Parameters: /// - mnemonic: The mnemonic phrase - /// - passphrase: Optional BIP39 passphrase /// - accountOptions: Account creation options /// - Returns: The wallet ID @discardableResult @@ -582,10 +584,12 @@ public class WalletManager { // MARK: - Serialization - /// Add a wallet from mnemonic and return serialized wallet bytes + /// Add a wallet from mnemonic (empty BIP39 passphrase) and return + /// serialized wallet bytes. key-wallet's serialized mnemonic wallet + /// shape carries no passphrase (rust-dashcore #747), so there is no + /// passphrase parameter here. /// - Parameters: /// - mnemonic: The mnemonic phrase - /// - passphrase: Optional BIP39 passphrase /// - birthHeight: Optional birth height for wallet /// - accountOptions: Account creation options /// - downgradeToPublicKeyWallet: If true, creates a watch-only or externally signable wallet @@ -593,19 +597,11 @@ public class WalletManager { /// - Returns: Tuple containing (walletId: Data, serializedWallet: Data) public func addWalletAndSerialize( mnemonic: String, - passphrase: String? = nil, birthHeight: UInt32 = 0, accountOptions: AccountCreationOption = .default, downgradeToPublicKeyWallet: Bool = false, allowExternalSigning: Bool = false ) throws -> (walletId: Data, serializedWallet: Data) { - if let passphrase, !passphrase.isEmpty { - throw KeyWalletError.invalidInput( - "BIP-39 passphrase support was removed upstream " + - "(rust-dashcore #747); pass nil or an empty string" - ) - } - var error = FFIError() var walletBytesPtr: UnsafeMutablePointer? var walletBytesLen: size_t = 0 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 8b7025e72b3..105cc6a0765 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -153,11 +153,26 @@ struct PlatformWalletNativeTeardownCalls: @unchecked Sendable { /// before hopping to the destroy queue. Every field is a plain value type. struct PlatformWalletCreateParams: Sendable { let mnemonic: String + /// Optional BIP-39 passphrase ("25th word"). `nil` / empty means none + /// and produces exactly the wallet a passphrase-less create does. + let seedPassphrase: String? let network: Network let accountOptions: UInt32 let birthHeight: UInt32? } +/// `body` receives a C string for `passphrase` (or `nil` when absent), the +/// shape the `_with_passphrase_` FFI takes: NULL means "no passphrase". +private func withOptionalPassphraseCString( + _ passphrase: String?, + _ body: (UnsafePointer?) throws -> R +) rethrows -> R { + if let passphrase, !passphrase.isEmpty { + return try passphrase.withCString { try body($0) } + } + return try body(nil) +} + /// Native entry point used by the off-main create orchestration in /// [`PlatformWalletManager.performCreateWallet`]. Same design as /// [`PlatformWalletNativeTeardownCalls`]: injecting the function (rather @@ -170,7 +185,7 @@ struct PlatformWalletCreateParams: Sendable { /// a process-global registry, so an arbitrary non-zero test value is not /// guaranteed to miss a live Rust entry owned by another test. struct PlatformWalletNativeCreateCalls: @unchecked Sendable { - /// Mirrors `platform_wallet_manager_create_wallet_from_mnemonic_with_birth_height`, + /// Mirrors `platform_wallet_manager_create_wallet_from_mnemonic_with_passphrase_and_birth_height`, /// folding the two out-params into the return value (the 32-byte wallet /// id already copied into a `Data`). typealias Call = @Sendable (Handle, PlatformWalletCreateParams) @@ -184,16 +199,19 @@ struct PlatformWalletNativeCreateCalls: @unchecked Sendable { var walletId: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) let result = params.mnemonic.withCString { mnemonicPtr in - platform_wallet_manager_create_wallet_from_mnemonic_with_birth_height( - managerHandle, - mnemonicPtr, - params.network.ffiValue, - params.accountOptions, - params.birthHeight != nil, - params.birthHeight ?? 0, - &walletHandle, - &walletId - ) + withOptionalPassphraseCString(params.seedPassphrase) { passphrasePtr in + platform_wallet_manager_create_wallet_from_mnemonic_with_passphrase_and_birth_height( + managerHandle, + mnemonicPtr, + passphrasePtr, + params.network.ffiValue, + params.accountOptions, + params.birthHeight != nil, + params.birthHeight ?? 0, + &walletHandle, + &walletId + ) + } } let idData = withUnsafeBytes(of: &walletId) { Data($0) } return (result, walletHandle, idData) @@ -984,9 +1002,17 @@ public class PlatformWalletManager: ObservableObject { /// (including DashPay payments) received before this device knew the wallet; /// without it, history — and the coreHeight rescan backfill — is clamped to /// the tip. `Some(h)` pins a known funding height. + /// + /// `seedPassphrase` is the optional BIP-39 passphrase ("25th word"). + /// `nil` / empty means none. A non-empty passphrase folds into the seed + /// (`PBKDF2(mnemonic, passphrase)`), so the wallet id differs from the + /// passphrase-less one. The caller must persist it with + /// `WalletStorage.storePassphrase(_:for:)` under the returned wallet id, + /// or the resolver-backed signer will derive the wrong keys. @discardableResult public func createWallet( mnemonic: String, + seedPassphrase: String? = nil, network: Network, name: String? = nil, createDefaultAccounts: Bool = true, @@ -999,6 +1025,7 @@ public class PlatformWalletManager: ObservableObject { fields: [ "birth_height_provided": .boolean(birthHeight != nil), "network": .publicText(String(describing: network)), + "seed_passphrase": .boolean(!(seedPassphrase ?? "").isEmpty), "source": .publicText("mnemonic"), ] ) @@ -1010,16 +1037,19 @@ public class PlatformWalletManager: ObservableObject { do { try mnemonic.withCString { mnemonicPtr in - try platform_wallet_manager_create_wallet_from_mnemonic_with_birth_height( - handle, - mnemonicPtr, - network.ffiValue, - accountOptions, - birthHeight != nil, - birthHeight ?? 0, - &walletHandle, - &walletId - ).check() + try withOptionalPassphraseCString(seedPassphrase) { passphrasePtr in + try platform_wallet_manager_create_wallet_from_mnemonic_with_passphrase_and_birth_height( + handle, + mnemonicPtr, + passphrasePtr, + network.ffiValue, + accountOptions, + birthHeight != nil, + birthHeight ?? 0, + &walletHandle, + &walletId + ).check() + } } } catch { SDKLogger.event( @@ -1031,7 +1061,7 @@ public class PlatformWalletManager: ObservableObject { "source": .publicText("mnemonic"), ], error: error, - redacting: [mnemonic] + redacting: [mnemonic, seedPassphrase ?? ""] ) throw error } @@ -1067,9 +1097,12 @@ public class PlatformWalletManager: ObservableObject { /// (native create + publish); [`shutdown()`] drains admitted creates /// before taking the handle, so a create whose FFI persisted wallet /// data can never be failed retroactively by a concurrent teardown. + /// + /// `seedPassphrase` follows the sync overload's contract. @discardableResult public func createWallet( mnemonic: String, + seedPassphrase: String? = nil, network: Network, name: String? = nil, createDefaultAccounts: Bool = true, @@ -1085,6 +1118,7 @@ public class PlatformWalletManager: ObservableObject { let h = handle let params = PlatformWalletCreateParams( mnemonic: mnemonic, + seedPassphrase: seedPassphrase, network: network, accountOptions: createDefaultAccounts ? 1 : 0, birthHeight: birthHeight) @@ -1145,6 +1179,7 @@ public class PlatformWalletManager: ObservableObject { "birth_height_provided": .boolean(params.birthHeight != nil), "network": .publicText(String(describing: params.network)), "off_main_thread": .boolean(offMain), + "seed_passphrase": .boolean(!(params.seedPassphrase ?? "").isEmpty), "source": .publicText("mnemonic"), ] ) @@ -1163,7 +1198,7 @@ public class PlatformWalletManager: ObservableObject { "source": .publicText("mnemonic"), ], error: PlatformWalletError(code: result.code, message: result.message), - redacting: [params.mnemonic] + redacting: [params.mnemonic, params.seedPassphrase ?? ""] ) return .failure(PlatformWalletError(code: result.code, message: result.message)) } @@ -1794,11 +1829,12 @@ public class PlatformWalletManager: ObservableObject { // (loadFromPersistor's log-and-continue) is unchanged. do { let storedMarker = persistenceHandler?.seedBindingMarker(walletId: walletId) - // Attribute-only stamp of the mnemonic Keychain item (secret never + // Attribute-only stamp of the mnemonic Keychain item plus the + // passphrase item when the wallet has one (secrets never // materialized). Rust binds the marker to it, so any rewrite of - // the item invalidates the cached verification. `nil` (attributes - // unreadable) disables the cache for this launch — Rust then - // always runs the full check and hands back no marker. + // either item invalidates the cached verification. `nil` + // (attributes unreadable) disables the cache for this launch — + // Rust then always runs the full check and hands back no marker. let keychainStamp = walletStorage.mnemonicKeychainStamp(for: walletId) // Set only when a full verification ran and bound — the signal to // persist the fresh marker. Freed unconditionally below. @@ -2292,8 +2328,13 @@ public class PlatformWalletManager: ObservableObject { let remaining = try persistenceHandler.walletRowCountAcrossNetworks(walletId: walletId) if remaining == 0 { let storage = WalletStorage() - // Delete metadata first so the mnemonic remains available for retry. + // Delete metadata first so the mnemonic remains available for + // retry. The passphrase goes before the mnemonic for the same + // reason: a passphrase orphaned without its mnemonic is inert, + // a mnemonic orphaned without its passphrase resolves to the + // wrong wallet. try storage.deleteMetadata(for: walletId) + try storage.deletePassphrase(for: walletId) try storage.deleteMnemonic(for: walletId) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md index a08b9d2bc7d..9125feb09ae 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -31,18 +31,18 @@ The main entry point for Platform Wallet functionality. **From Mnemonic:** ```swift -static func fromMnemonic(_ mnemonic: String, passphrase: String? = nil) throws -> PlatformWallet +static func fromMnemonic(_ mnemonic: String, network: Network = .testnet) throws -> PlatformWallet ``` -Creates a Platform Wallet from a BIP39 mnemonic phrase with optional passphrase. +Creates a Platform Wallet from a BIP39 mnemonic phrase (empty passphrase). +For a BIP39 passphrase ("25th word") derive the seed first and use `fromSeed`, +or create through `PlatformWalletManager.createWallet(mnemonic:seedPassphrase:...)`. Example: ```swift let wallet = try PlatformWallet.fromMnemonic("word1 word2 ... word12") -let walletWithPassphrase = try PlatformWallet.fromMnemonic( - "word1 word2 ... word12", - passphrase: "my-secret-passphrase" -) +let seed = try Mnemonic.toSeed(mnemonic: "word1 word2 ... word12", passphrase: "my-secret-passphrase") +let walletWithPassphrase = try PlatformWallet.fromSeed(seed) ``` **From Seed:** diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 994f45f7671..f2216fa892d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -579,6 +579,7 @@ struct ContentView: View { var failures: [String] = [] for entry in orphanEntries { do { + try storage.deletePassphrase(for: entry.walletId) try storage.deleteMnemonic(for: entry.walletId) } catch { failures.append("\(entry.displayName): \(error.localizedDescription)") diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletCreateWalletTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletCreateWalletTests.swift index dceccf470aa..6618c3ceaa2 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletCreateWalletTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletCreateWalletTests.swift @@ -22,7 +22,7 @@ final class PlatformWalletCreateWalletTests: XCTestCase { private let gate: DispatchSemaphore? private let failingCode: PlatformWalletFFIResultCode? private let eventLog: EventLog? - private var invocations: [(handle: Handle, mnemonic: String, ranOnMainThread: Bool)] = [] + private var invocations: [(handle: Handle, mnemonic: String, seedPassphrase: String?, ranOnMainThread: Bool)] = [] private var inFlight = 0 private var maxInFlightSeen = 0 @@ -41,7 +41,7 @@ final class PlatformWalletCreateWalletTests: XCTestCase { params: PlatformWalletCreateParams ) -> (result: PlatformWalletFFIResult, walletHandle: Handle, walletId: Data) { lock.withLock { - invocations.append((handle, params.mnemonic, Thread.isMainThread)) + invocations.append((handle, params.mnemonic, params.seedPassphrase, Thread.isMainThread)) inFlight += 1 maxInFlightSeen = max(maxInFlightSeen, inFlight) } @@ -72,6 +72,7 @@ final class PlatformWalletCreateWalletTests: XCTestCase { var count: Int { lock.withLock { invocations.count } } var handles: [Handle] { lock.withLock { invocations.map(\.handle) } } + var seedPassphrases: [String?] { lock.withLock { invocations.map(\.seedPassphrase) } } var mainThreadFlags: [Bool] { lock.withLock { invocations.map(\.ranOnMainThread) } } /// Peak number of native creates running at once — 1 proves the /// shared queue actually serialized concurrent callers. @@ -138,11 +139,24 @@ final class PlatformWalletCreateWalletTests: XCTestCase { XCTAssertEqual(recorder.count, 1) XCTAssertEqual(recorder.handles, [42]) XCTAssertEqual(recorder.mainThreadFlags, [false], "the native create must run off the main thread") + XCTAssertEqual(recorder.seedPassphrases, [nil], "no passphrase by default") XCTAssertEqual(wallet.walletId, Data(repeating: 1, count: 32)) XCTAssertTrue(manager.wallets[wallet.walletId] === wallet) await manager.shutdown() } + /// The optional BIP-39 passphrase reaches the native create verbatim — + /// the FFI side NFKD-normalizes it, Swift must not touch it. + func testCreateForwardsTheSeedPassphrase() async throws { + let recorder = CreateRecorder() + let manager = makeManager(handle: 43, createRecorder: recorder) + + _ = try await manager.createWallet(mnemonic: "m", seedPassphrase: " Trézor ", network: .testnet) + + XCTAssertEqual(recorder.seedPassphrases, [" Trézor "]) + await manager.shutdown() + } + // MARK: - Error mapping func testCreateErrorCodeMapsToTypedError() async { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SeedPassphraseResolverTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SeedPassphraseResolverTests.swift new file mode 100644 index 00000000000..d9dcc200962 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SeedPassphraseResolverTests.swift @@ -0,0 +1,197 @@ +import XCTest + +@testable import SwiftDashSDK + +/// The BIP-39 passphrase ("25th word") leg of the resolver path: a wallet +/// whose `WalletStorage` holds a passphrase next to its mnemonic must sign +/// with the passphrase-derived keys, and a wallet without one must keep +/// signing exactly as before. Runs entirely in-process against an +/// in-memory storage — no Keychain, no network. +@MainActor +final class SeedPassphraseResolverTests: XCTestCase { + + // Canonical BIP-39 test vector (all-zero entropy). + private let mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private let passphrase = "TREZOR" + private let path = "m/9'/1'/5'/0'/0'/3'/2'" + + /// In-memory `WalletStorage` holding both the mnemonic and the optional + /// passphrase, mirroring the real storage's per-item contract. + private final class InMemoryWalletStorage: WalletStorage { + private var mnemonics: [Data: Data] = [:] + private var passphrases: [Data: Data] = [:] + private let lock = NSLock() + + override func storeMnemonic(_ mnemonic: String, for walletId: Data) throws { + lock.lock(); defer { lock.unlock() } + mnemonics[walletId] = Data(mnemonic.utf8) + } + + override func retrieveMnemonicUTF8Bytes(for walletId: Data) throws -> Data { + lock.lock(); defer { lock.unlock() } + guard let data = mnemonics[walletId], !data.isEmpty else { + throw WalletStorageError.mnemonicNotFound + } + return data + } + + override func deleteMnemonic(for walletId: Data) throws { + lock.lock(); defer { lock.unlock() } + mnemonics[walletId] = nil + } + + override func mnemonicAvailability(for walletId: Data) -> MnemonicAvailability { + lock.lock(); defer { lock.unlock() } + return mnemonics[walletId] != nil ? .present : .absent + } + + override func storePassphrase(_ passphrase: String, for walletId: Data) throws { + guard !passphrase.isEmpty else { throw WalletStorageError.emptyPassphrase } + lock.lock(); defer { lock.unlock() } + passphrases[walletId] = Data(passphrase.utf8) + } + + override func retrievePassphraseUTF8Bytes(for walletId: Data) throws -> Data { + lock.lock(); defer { lock.unlock() } + guard let data = passphrases[walletId], !data.isEmpty else { + throw WalletStorageError.passphraseNotFound + } + return data + } + + override func passphraseAvailability(for walletId: Data) -> MnemonicAvailability { + lock.lock(); defer { lock.unlock() } + return passphrases[walletId] != nil ? .present : .absent + } + + override func deletePassphrase(for walletId: Data) throws { + lock.lock(); defer { lock.unlock() } + passphrases[walletId] = nil + } + } + + /// Compressed pubkey at `path` derived locally from `(mnemonic, passphrase)`. + private func expectedPubkey(passphrase: String?) throws -> Data { + let seed = try Mnemonic.toSeed(mnemonic: mnemonic, passphrase: passphrase) + let wallet = try Wallet(seed: seed, network: .testnet) + return try XCTUnwrap(Data(hexString: wallet.derivePublicKey(path: path))) + } + + /// Sign through the resolver FFI for `walletId` and return the error tag + /// (`.ok` on success) — the same entry point `KeychainSigner` uses for + /// resolver-backed identity keys. + private func signViaResolver( + storage: WalletStorage, + walletId: Data, + expectedKey: Data + ) -> SignWithMnemonicResolverError { + let resolver = MnemonicResolver(storage: storage) + var signature = [UInt8](repeating: 0, count: 128) + var signatureLen: UInt = 0 + var errorTag: UInt8 = 0 + let data = Data("identity state transition".utf8) + let rc: Int32 = withExtendedLifetime(resolver) { + walletId.withUnsafeBytes { idPtr in + path.withCString { pathPtr in + data.withUnsafeBytes { dataPtr in + expectedKey.withUnsafeBytes { keyPtr in + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver.handle, + idPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), + pathPtr, + dataPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), + UInt(data.count), + 0, // ECDSA_SECP256K1 + Network.testnet.ffiValue, + keyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), + UInt(expectedKey.count), + &signature, + UInt(signature.count), + &signatureLen, + &errorTag + ) + } + } + } + } + } + if rc == 0 { + XCTAssertEqual(signatureLen, 65, "compact-recoverable ECDSA signature") + return .ok + } + return SignWithMnemonicResolverError(rawValue: errorTag) ?? .resolverFailed + } + + func testToSeedHonoursThePassphraseAndDetectsLanguage() throws { + let plain = try Mnemonic.toSeed(mnemonic: mnemonic) + let withPassphrase = try Mnemonic.toSeed(mnemonic: mnemonic, passphrase: passphrase) + XCTAssertEqual(plain.count, 64) + XCTAssertEqual( + plain.map { String(format: "%02x", $0) }.joined(), + "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4") + XCTAssertEqual( + withPassphrase.map { String(format: "%02x", $0) }.joined(), + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04") + XCTAssertEqual(try Mnemonic.toSeed(mnemonic: mnemonic, passphrase: ""), plain, + "an empty passphrase is no passphrase") + + // The any-language FFI must accept a non-English phrase, which the + // English-only key-wallet-ffi helper rejected. + let japanese = "あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あおぞら" + XCTAssertEqual(try Mnemonic.toSeed(mnemonic: japanese).count, 64) + } + + func testPassphraseWalletSignsWithPassphraseDerivedKey() throws { + let walletId = Data(repeating: 0x7C, count: 32) + let storage = InMemoryWalletStorage() + try storage.storeMnemonic(mnemonic, for: walletId) + try storage.storePassphrase(passphrase, for: walletId) + + let passphraseKey = try expectedPubkey(passphrase: passphrase) + let plainKey = try expectedPubkey(passphrase: nil) + XCTAssertNotEqual(passphraseKey, plainKey) + + XCTAssertEqual( + signViaResolver(storage: storage, walletId: walletId, expectedKey: passphraseKey), .ok, + "the resolver must hand Rust the stored passphrase") + XCTAssertEqual( + signViaResolver(storage: storage, walletId: walletId, expectedKey: plainKey), + .pubkeyMismatch, + "the passphrase-less key must not bind for a passphrase wallet") + } + + func testWalletWithoutPassphraseSignsAsBefore() throws { + let walletId = Data(repeating: 0x7D, count: 32) + let storage = InMemoryWalletStorage() + try storage.storeMnemonic(mnemonic, for: walletId) + + let plainKey = try expectedPubkey(passphrase: nil) + XCTAssertEqual( + signViaResolver(storage: storage, walletId: walletId, expectedKey: plainKey), .ok) + } + + func testDeletingThePassphraseChangesWhichKeyBinds() throws { + let walletId = Data(repeating: 0x7E, count: 32) + let storage = InMemoryWalletStorage() + try storage.storeMnemonic(mnemonic, for: walletId) + try storage.storePassphrase(passphrase, for: walletId) + XCTAssertTrue(storage.hasPassphrase(for: walletId)) + + try storage.deletePassphrase(for: walletId) + XCTAssertFalse(storage.hasPassphrase(for: walletId)) + XCTAssertEqual( + signViaResolver( + storage: storage, walletId: walletId, expectedKey: try expectedPubkey(passphrase: nil)), + .ok) + } + + func testEmptyPassphraseIsRejectedByStorage() { + let storage = InMemoryWalletStorage() + XCTAssertThrowsError(try storage.storePassphrase("", for: Data(repeating: 1, count: 32))) { error in + guard case WalletStorageError.emptyPassphrase = error else { + return XCTFail("expected emptyPassphrase, got \(error)") + } + } + } +} From 1dedb36da1799184ac2234b5730d40731286f3ab Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 17:07:37 -0500 Subject: [PATCH 2/2] refactor(sdk): dedupe the seed-passphrase plumbing create_wallet_from_mnemonic now calls seed_from_mnemonic instead of repeating it; ResolveSeedError derives thiserror; the resolver sign FFI maps errors to tags in one table; WalletStorage's per-wallet accessors share one set of Keychain primitives; MaskedMnemonicUTF8 is renamed MaskedSecretUTF8 now that it also masks the passphrase; Mnemonic.toSeed reuses withOptionalPassphraseCString. No behaviour change; public Swift surface and FFI symbols are identical. Co-Authored-By: Claude Fable 5.1 --- .../rs-platform-wallet-ffi/src/manager.rs | 40 +-- .../src/sign_with_mnemonic_resolver.rs | 26 +- .../src/manager/wallet_lifecycle.rs | 5 +- packages/rs-sdk-ffi/src/mnemonic_resolver.rs | 44 +--- .../Core/Wallet/WalletStorage.swift | 229 +++++++++--------- .../FFI/MnemonicResolverAndPersister.swift | 10 +- .../SwiftDashSDK/KeyWallet/Mnemonic.swift | 10 +- .../PlatformWalletManager.swift | 6 +- 8 files changed, 164 insertions(+), 206 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 00a761366ed..dd493df9af7 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -1189,16 +1189,17 @@ mod tests { ) .unwrap(); let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); - - let mut seed = [0u8; 64]; - let rc = unsafe { + let to_seed = |passphrase: *const std::os::raw::c_char, out: &mut [u8]| unsafe { platform_wallet_mnemonic_to_seed( phrase.as_ptr(), - std::ptr::null(), - seed.as_mut_ptr(), - seed.len(), + passphrase, + out.as_mut_ptr(), + out.len(), ) }; + + let mut seed = [0u8; 64]; + let rc = to_seed(std::ptr::null(), &mut seed); assert_eq!(rc.code, PlatformWalletFFIResultCode::Success); assert_eq!( hex(&seed), @@ -1207,27 +1208,13 @@ mod tests { let empty = std::ffi::CString::new("").unwrap(); let mut seed_empty = [0u8; 64]; - let rc = unsafe { - platform_wallet_mnemonic_to_seed( - phrase.as_ptr(), - empty.as_ptr(), - seed_empty.as_mut_ptr(), - seed_empty.len(), - ) - }; + let rc = to_seed(empty.as_ptr(), &mut seed_empty); assert_eq!(rc.code, PlatformWalletFFIResultCode::Success); assert_eq!(seed_empty, seed, "an empty passphrase must equal NULL"); let trezor = std::ffi::CString::new("TREZOR").unwrap(); let mut seed_pp = [0u8; 64]; - let rc = unsafe { - platform_wallet_mnemonic_to_seed( - phrase.as_ptr(), - trezor.as_ptr(), - seed_pp.as_mut_ptr(), - seed_pp.len(), - ) - }; + let rc = to_seed(trezor.as_ptr(), &mut seed_pp); assert_eq!(rc.code, PlatformWalletFFIResultCode::Success); assert_eq!( hex(&seed_pp), @@ -1235,14 +1222,7 @@ mod tests { ); let mut short = [0u8; 32]; - let rc = unsafe { - platform_wallet_mnemonic_to_seed( - phrase.as_ptr(), - std::ptr::null(), - short.as_mut_ptr(), - short.len(), - ) - }; + let rc = to_seed(std::ptr::null(), &mut short); assert_eq!(rc.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); } diff --git a/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs b/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs index ebbc918cb85..b0e190adf14 100644 --- a/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs +++ b/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs @@ -216,18 +216,20 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_resolver_and_path( &*(wallet_id_bytes as *const [u8; 32]), ) { Ok(seed) => seed, - Err(ResolveSeedError::NotFound) => return fail(SIGN_WITH_RESOLVER_ERR_RESOLVER_NOT_FOUND), - Err(ResolveSeedError::BufferTooSmall) => { - return fail(SIGN_WITH_RESOLVER_ERR_BUFFER_TOO_SMALL) - } - Err(ResolveSeedError::InvalidUtf8) => return fail(SIGN_WITH_RESOLVER_ERR_INVALID_UTF8), - Err(ResolveSeedError::InvalidMnemonic) => { - return fail(SIGN_WITH_RESOLVER_ERR_INVALID_MNEMONIC) - } - Err(ResolveSeedError::ResolverFailed(_)) - | Err(ResolveSeedError::InvalidMnemonicLength(_)) - | Err(ResolveSeedError::InvalidPassphraseLength(_)) => { - return fail(SIGN_WITH_RESOLVER_ERR_RESOLVER_FAILED) + Err(e) => { + return fail(match e { + ResolveSeedError::NotFound => SIGN_WITH_RESOLVER_ERR_RESOLVER_NOT_FOUND, + ResolveSeedError::BufferTooSmall => SIGN_WITH_RESOLVER_ERR_BUFFER_TOO_SMALL, + ResolveSeedError::InvalidUtf8 => SIGN_WITH_RESOLVER_ERR_INVALID_UTF8, + ResolveSeedError::InvalidMnemonic => SIGN_WITH_RESOLVER_ERR_INVALID_MNEMONIC, + // Host-side framing bugs and Keychain failures share the + // generic tag; the distinction is not actionable in Swift. + ResolveSeedError::ResolverFailed(_) + | ResolveSeedError::InvalidMnemonicLength(_) + | ResolveSeedError::InvalidPassphraseLength(_) => { + SIGN_WITH_RESOLVER_ERR_RESOLVER_FAILED + } + }); } }; diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 1b1a20ad200..a7ef8e395ab 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -134,11 +134,8 @@ impl PlatformWalletManager

{ accounts: WalletAccountCreationOptions, birth_height_override: Option, ) -> Result, PlatformWalletError> { - let mnemonic = parse_mnemonic_any_language(mnemonic_phrase) - .map_err(|e| PlatformWalletError::WalletCreation(format!("Invalid mnemonic: {}", e)))?; // `to_seed` NFKD-normalizes the passphrase per BIP-39. - let seed = zeroize::Zeroizing::new(mnemonic.to_seed(passphrase)); - drop(mnemonic); + let seed = seed_from_mnemonic(mnemonic_phrase, passphrase)?; let wallet = Wallet::from_seed_bytes(*seed, network, accounts).map_err(|e| { PlatformWalletError::WalletCreation(format!( "Failed to create wallet from mnemonic: {}", diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver.rs index a93ffd21af4..814c5d87d2b 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver.rs @@ -224,60 +224,42 @@ pub unsafe extern "C" fn dash_sdk_mnemonic_resolver_destroy(handle: *mut Mnemoni /// match, the length check, the UTF-8 check and the wordlist parse; /// this enum is the single shape those checks now report through so /// each FFI entry point only has to map it onto its own error surface. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// The `Display` text is what the FFI entry points hand back as their +/// error detail, so it deliberately carries no payload: a resolver +/// return code or a bogus length is host-side framing noise, not +/// something a caller can act on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum ResolveSeedError { /// The resolver reported no stored mnemonic for this wallet id /// ([`mnemonic_resolver_result::NOT_FOUND`]). + #[error("mnemonic resolver: no mnemonic stored for the supplied wallet_id")] NotFound, /// The mnemonic or the passphrase did not fit its buffer /// ([`mnemonic_resolver_result::BUFFER_TOO_SMALL`]). + #[error("mnemonic resolver: mnemonic or passphrase exceeded the FFI buffer capacity")] BufferTooSmall, /// Any other resolver return code (Keychain locked / denied, …). /// Carries the raw code. + #[error("mnemonic resolver: failed (other / Keychain access error)")] ResolverFailed(i32), /// The resolver claimed a mnemonic length of zero or beyond the /// buffer capacity — a framing bug on the host side. + #[error("mnemonic resolver: returned invalid length")] InvalidMnemonicLength(usize), /// The resolver claimed a passphrase length beyond the buffer /// capacity — a framing bug on the host side. + #[error("mnemonic resolver: returned invalid length")] InvalidPassphraseLength(usize), /// Mnemonic or passphrase bytes were not valid UTF-8. + #[error("mnemonic resolver: returned invalid UTF-8")] InvalidUtf8, /// The mnemonic matched no supported BIP-39 wordlist / failed its /// checksum. + #[error("mnemonic resolver: returned an invalid mnemonic")] InvalidMnemonic, } -impl std::fmt::Display for ResolveSeedError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NotFound => { - write!( - f, - "mnemonic resolver: no mnemonic stored for the supplied wallet_id" - ) - } - Self::BufferTooSmall => write!( - f, - "mnemonic resolver: mnemonic or passphrase exceeded the FFI buffer capacity" - ), - Self::ResolverFailed(_) => { - write!( - f, - "mnemonic resolver: failed (other / Keychain access error)" - ) - } - Self::InvalidMnemonicLength(_) | Self::InvalidPassphraseLength(_) => { - write!(f, "mnemonic resolver: returned invalid length") - } - Self::InvalidUtf8 => write!(f, "mnemonic resolver: returned invalid UTF-8"), - Self::InvalidMnemonic => { - write!(f, "mnemonic resolver: returned an invalid mnemonic") - } - } - } -} - /// Fire the resolver once for `wallet_id` and return the wallet's /// 64-byte BIP-39 seed — `PBKDF2(mnemonic, passphrase)` with the /// passphrase the host stored for this wallet (empty when it has none). diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift index d2150b6d9b5..5d7568b3aad 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift @@ -68,20 +68,19 @@ public class WalletStorage { public init() {} - // MARK: - Per-Wallet Mnemonic Storage + // MARK: - Shared Keychain Item Primitives // - // Multi-wallet variant keyed by the 32-byte walletId. Stores each - // mnemonic at account `wallet.mnemonic.` so any - // number of wallets can coexist. - - private func perWalletMnemonicAccount(for walletId: Data) -> String { + // Every per-wallet item (mnemonic, passphrase, metadata) is a + // `kSecClassGenericPassword` row under `keychainService` at + // `.`. The typed accessors below differ only in + // that prefix and in the error they raise for a missing item, so the + // Keychain queries themselves live here once. + + /// `.<64-char-hex-walletId>` — the account layout shared by + /// every per-wallet item. + private func perWalletAccount(_ prefix: String, for walletId: Data) -> String { let hex = walletId.map { String(format: "%02x", $0) }.joined() - return "\(mnemonicKeychainAccount).\(hex)" - } - - /// Store a mnemonic keyed by wallet id. - public func storeMnemonic(_ mnemonic: String, for walletId: Data) throws { - try replaceSecret(Data(mnemonic.utf8), account: perWalletMnemonicAccount(for: walletId)) + return "\(prefix).\(hex)" } /// Delete-then-add write of a secret at `account` with the @@ -113,12 +112,10 @@ public class WalletStorage { } } - /// Retrieve the mnemonic UTF-8 bytes keyed by wallet id. - /// - /// Returning raw bytes lets security-sensitive call sites avoid - /// materializing a Swift `String` unless they truly need one. - public func retrieveMnemonicUTF8Bytes(for walletId: Data) throws -> Data { - let account = perWalletMnemonicAccount(for: walletId) + /// Read the secret bytes at `account`. A missing item — and an item + /// whose payload is empty, which no writer here produces — throws + /// `notFound`; anything else throws the raw `OSStatus`. + private func secretData(account: String, notFound: WalletStorageError) throws -> Data { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, @@ -130,24 +127,108 @@ public class WalletStorage { let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { - throw WalletStorageError.mnemonicNotFound + throw notFound } guard status == errSecSuccess else { throw WalletStorageError.keychainError(status) } guard let data = result as? Data, !data.isEmpty else { - throw WalletStorageError.mnemonicNotFound + throw notFound } return data } + /// `secretData(account:notFound:)` decoded as a non-empty UTF-8 string. + private func secretString(account: String, notFound: WalletStorageError) throws -> String { + let data = try secretData(account: account, notFound: notFound) + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { + throw notFound + } + return text + } + + /// Lookup of one item that returns its attributes rather than its + /// payload — the shape both `availability(account:)` and + /// `itemStamp(account:)` need, and the reason neither materializes a + /// secret. + private func attributeQuery(account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true + ] + } + + /// Attribute-only presence of one Keychain item, keeping "no such item" + /// apart from "could not tell". No secret is materialized. + private func availability(account: String) -> MnemonicAvailability { + var result: AnyObject? + switch SecItemCopyMatching(attributeQuery(account: account) as CFDictionary, &result) { + case errSecSuccess: return .present + case errSecItemNotFound: return .absent + case let status: return .unavailable(status) + } + } + + /// Attribute-only creation + modification stamp of one Keychain item, or + /// `nil` when the item is missing or its attributes are unreadable. + private func itemStamp(account: String) -> String? { + var result: AnyObject? + let status = SecItemCopyMatching(attributeQuery(account: account) as CFDictionary, &result) + guard status == errSecSuccess, + let attrs = result as? [String: Any], + let modified = attrs[kSecAttrModificationDate as String] as? Date else { + return nil + } + let created = attrs[kSecAttrCreationDate as String] as? Date ?? modified + // Millisecond precision; both dates so delete-then-add and in-place + // update are each guaranteed to change the stamp. + return "c\(Int64(created.timeIntervalSince1970 * 1000))" + + "-m\(Int64(modified.timeIntervalSince1970 * 1000))" + } + + private func deleteSecret(account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: account + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw WalletStorageError.keychainError(status) + } + } + + // MARK: - Per-Wallet Mnemonic Storage + // + // Multi-wallet variant keyed by the 32-byte walletId. Stores each + // mnemonic at account `wallet.mnemonic.` so any + // number of wallets can coexist. + + private func perWalletMnemonicAccount(for walletId: Data) -> String { + perWalletAccount(mnemonicKeychainAccount, for: walletId) + } + + /// Store a mnemonic keyed by wallet id. + public func storeMnemonic(_ mnemonic: String, for walletId: Data) throws { + try replaceSecret(Data(mnemonic.utf8), account: perWalletMnemonicAccount(for: walletId)) + } + + /// Retrieve the mnemonic UTF-8 bytes keyed by wallet id. + /// + /// Returning raw bytes lets security-sensitive call sites avoid + /// materializing a Swift `String` unless they truly need one. + public func retrieveMnemonicUTF8Bytes(for walletId: Data) throws -> Data { + try secretData( + account: perWalletMnemonicAccount(for: walletId), notFound: .mnemonicNotFound) + } + /// Retrieve a mnemonic keyed by wallet id. public func retrieveMnemonic(for walletId: Data) throws -> String { - let data = try retrieveMnemonicUTF8Bytes(for: walletId) - guard let mnemonic = String(data: data, encoding: .utf8), !mnemonic.isEmpty else { - throw WalletStorageError.mnemonicNotFound - } - return mnemonic + try secretString( + account: perWalletMnemonicAccount(for: walletId), notFound: .mnemonicNotFound) } /// Three-way answer to "can this wallet's mnemonic be read right now?". @@ -170,20 +251,7 @@ public class WalletStorage { /// Whether the wallet's mnemonic is readable, keeping "no such item" apart /// from "could not tell". Attribute-only; no secret is materialized. public func mnemonicAvailability(for walletId: Data) -> MnemonicAvailability { - let account = perWalletMnemonicAccount(for: walletId) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: account, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnAttributes as String: true - ] - var result: AnyObject? - switch SecItemCopyMatching(query as CFDictionary, &result) { - case errSecSuccess: return .present - case errSecItemNotFound: return .absent - case let status: return .unavailable(status) - } + availability(account: perWalletMnemonicAccount(for: walletId)) } /// Cheap existence check used by signer preflight paths. @@ -238,30 +306,6 @@ public class WalletStorage { } } - /// Attribute-only creation + modification stamp of one Keychain item, or - /// `nil` when the item is missing or its attributes are unreadable. - private func itemStamp(account: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: account, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnAttributes as String: true - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, - let attrs = result as? [String: Any], - let modified = attrs[kSecAttrModificationDate as String] as? Date else { - return nil - } - let created = attrs[kSecAttrCreationDate as String] as? Date ?? modified - // Millisecond precision; both dates so delete-then-add and in-place - // update are each guaranteed to change the stamp. - return "c\(Int64(created.timeIntervalSince1970 * 1000))" - + "-m\(Int64(modified.timeIntervalSince1970 * 1000))" - } - /// Delete a mnemonic keyed by wallet id. Idempotent. /// /// Deletes only the mnemonic item; a passphrase stored for the same @@ -272,18 +316,6 @@ public class WalletStorage { try deleteSecret(account: perWalletMnemonicAccount(for: walletId)) } - private func deleteSecret(account: String) throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: account - ] - let status = SecItemDelete(query as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw WalletStorageError.keychainError(status) - } - } - // MARK: - Per-Wallet BIP-39 Passphrase Storage // // Optional "25th word". Stored as its own item next to the mnemonic @@ -293,8 +325,7 @@ public class WalletStorage { // as the mnemonic: the Keychain is the security boundary for both. private func perWalletPassphraseAccount(for walletId: Data) -> String { - let hex = walletId.map { String(format: "%02x", $0) }.joined() - return "\(Self.passphraseAccountPrefix).\(hex)" + perWalletAccount(Self.passphraseAccountPrefix, for: walletId) } /// Store the BIP-39 passphrase for `walletId`. An empty passphrase is @@ -312,51 +343,20 @@ public class WalletStorage { /// `passphraseNotFound` when the wallet has none — callers that only /// need to know whether one exists should use `hasPassphrase(for:)`. public func retrievePassphraseUTF8Bytes(for walletId: Data) throws -> Data { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: perWalletPassphraseAccount(for: walletId), - kSecReturnData as String: true - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { - throw WalletStorageError.passphraseNotFound - } - guard status == errSecSuccess else { - throw WalletStorageError.keychainError(status) - } - guard let data = result as? Data, !data.isEmpty else { - throw WalletStorageError.passphraseNotFound - } - return data + try secretData( + account: perWalletPassphraseAccount(for: walletId), notFound: .passphraseNotFound) } /// Retrieve the passphrase for `walletId` as a `String`. public func retrievePassphrase(for walletId: Data) throws -> String { - let data = try retrievePassphraseUTF8Bytes(for: walletId) - guard let passphrase = String(data: data, encoding: .utf8), !passphrase.isEmpty else { - throw WalletStorageError.passphraseNotFound - } - return passphrase + try secretString( + account: perWalletPassphraseAccount(for: walletId), notFound: .passphraseNotFound) } /// Whether the wallet's passphrase item is readable, keeping "no such /// item" apart from "could not tell". Attribute-only. public func passphraseAvailability(for walletId: Data) -> MnemonicAvailability { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: perWalletPassphraseAccount(for: walletId), - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnAttributes as String: true - ] - var result: AnyObject? - switch SecItemCopyMatching(query as CFDictionary, &result) { - case errSecSuccess: return .present - case errSecItemNotFound: return .absent - case let status: return .unavailable(status) - } + availability(account: perWalletPassphraseAccount(for: walletId)) } /// Cheap existence check: `true` only when a passphrase item is present. @@ -415,8 +415,7 @@ public class WalletStorage { // live here, not derived/cached state like sync heights. private func perWalletMetadataAccount(for walletId: Data) -> String { - let hex = walletId.map { String(format: "%02x", $0) }.joined() - return "\(Self.metadataAccountPrefix).\(hex)" + perWalletAccount(Self.metadataAccountPrefix, for: walletId) } /// Write (or replace) the metadata blob for `walletId`. Uses the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift index 4421bad6ec9..3c03d88f739 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift @@ -15,7 +15,7 @@ private func scrubBytes(_ bytes: inout [UInt8]) { /// mnemonic, and the BIP-39 passphrase when the wallet has one) while /// they sit on the Swift heap between the Keychain read and the final /// copy into Rust's `Zeroizing` buffer. -private final class MaskedMnemonicUTF8 { +private final class MaskedSecretUTF8 { private var maskedBytes: [UInt8] private var maskBytes: [UInt8] @@ -201,11 +201,11 @@ public final class MnemonicResolver: @unchecked Sendable { } } - let maskedMnemonic: MaskedMnemonicUTF8 - let maskedPassphrase: MaskedMnemonicUTF8? + let maskedMnemonic: MaskedSecretUTF8 + let maskedPassphrase: MaskedSecretUTF8? do { - maskedMnemonic = try MaskedMnemonicUTF8(plaintextUTF8Bytes: mnemonicUTF8Bytes) - maskedPassphrase = try passphraseUTF8Bytes.map { try MaskedMnemonicUTF8(plaintextUTF8Bytes: $0) } + maskedMnemonic = try MaskedSecretUTF8(plaintextUTF8Bytes: mnemonicUTF8Bytes) + maskedPassphrase = try passphraseUTF8Bytes.map { try MaskedSecretUTF8(plaintextUTF8Bytes: $0) } } catch { return .other } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift index a2980237508..1d5d0948d86 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Mnemonic.swift @@ -82,12 +82,11 @@ public class Mnemonic { var seed = [UInt8](repeating: 0, count: 64) var mnemonicBytes = [UInt8](mnemonicUTF8Bytes) + defer { scrubMnemonicBytes(&mnemonicBytes) } guard !mnemonicBytes.contains(0) else { - scrubMnemonicBytes(&mnemonicBytes) throw KeyWalletError.invalidInput("Mnemonic bytes must not contain NUL") } mnemonicBytes.append(0) - defer { scrubMnemonicBytes(&mnemonicBytes) } let result: PlatformWalletFFIResult = mnemonicBytes.withUnsafeBufferPointer { mnemonicBuf in guard let mnemonicBase = mnemonicBuf.baseAddress else { @@ -98,12 +97,9 @@ public class Mnemonic { seed.withUnsafeMutableBufferPointer { seedBuf -> PlatformWalletFFIResult in let seedPtr = seedBuf.baseAddress let seedLen = UInt(seedBuf.count) - if let passphrase, !passphrase.isEmpty { - return passphrase.withCString { passphraseCStr in - platform_wallet_mnemonic_to_seed(mnemonicCStr, passphraseCStr, seedPtr, seedLen) - } + return withOptionalPassphraseCString(passphrase) { passphraseCStr in + platform_wallet_mnemonic_to_seed(mnemonicCStr, passphraseCStr, seedPtr, seedLen) } - return platform_wallet_mnemonic_to_seed(mnemonicCStr, nil, seedPtr, seedLen) } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 105cc6a0765..f7f46578ee8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -162,8 +162,10 @@ struct PlatformWalletCreateParams: Sendable { } /// `body` receives a C string for `passphrase` (or `nil` when absent), the -/// shape the `_with_passphrase_` FFI takes: NULL means "no passphrase". -private func withOptionalPassphraseCString( +/// shape every passphrase-taking FFI export takes: NULL means "no +/// passphrase". Shared with `Mnemonic.toSeed` so the "empty is the same as +/// absent" rule is decided in exactly one place. +func withOptionalPassphraseCString( _ passphrase: String?, _ body: (UnsafePointer?) throws -> R ) rethrows -> R {