diff --git a/packages/rs-platform-wallet-ffi/src/identity_discovery.rs b/packages/rs-platform-wallet-ffi/src/identity_discovery.rs index 2d8c38bc2a3..d9a34e94794 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_discovery.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_discovery.rs @@ -2,10 +2,10 @@ //! platform-wallet [`IdentityWallet`](platform_wallet::IdentityWallet). //! //! Exposes [`platform_wallet_discover_identities`] which drives -//! `IdentityWallet::discover`: derives consecutive MASTER -//! authentication keys from the wallet's DIP-9 tree, queries Platform -//! for a registered identity bound to each key hash (unique -//! pubkey-hash lookup), and stops after `gap_limit` consecutive +//! `IdentityWallet::discover` (or `discover_from_master`): derives +//! consecutive MASTER authentication keys from the wallet's DIP-9 tree, +//! queries Platform for a registered identity bound to each key hash +//! (unique pubkey-hash lookup), and stops after `gap_limit` consecutive //! misses. //! //! Resume vs full rescan is controlled by `start_index_or_neg1`: @@ -15,6 +15,37 @@ //! - Pass `>= 0` to start scanning from that explicit identity index //! (typically `0` for a cold full rescan after a wallet import). //! +//! # Key source: chosen by wallet capability +//! +//! The DIP-9 derivation needs the wallet's private key material. The +//! source is selected by the in-process wallet's shape, NOT by whether +//! a resolver handle was supplied — the resolver is a *capability* the +//! Rust side consults only when it can't derive locally, not a command +//! that forces the resolver path: +//! +//! - **In-process wallet holds resident private keys** (`WalletType:: +//! Mnemonic` / `Seed` / `ExtendedPrivKey` — NOT external-signable and +//! NOT watch-only): drive the historical resident-wallet derive +//! (`discover`). The resolver handle is never touched, which also +//! skips a pointless iOS Keychain read. This keeps `createWallet(seed:)` +//! / raw-seed wallets working even when no BIP-39 mnemonic was ever +//! persisted to `WalletStorage`. +//! - **In-process wallet is external-signable / watch-only:** its seed +//! lives in iOS Keychain, NOT in process, so the resident derive would +//! fail with `External signable wallet has no private key`. In that +//! case, if `mnemonic_resolver_handle` is non-null, resolve the +//! wallet's mnemonic on demand via the Swift-owned +//! [`MnemonicResolverHandle`] (its `resolve` callback reads the +//! mnemonic from iOS Keychain keyed by the wallet handle's own +//! `wallet_id`), build the master `ExtendedPrivKey`, and drive +//! `discover_from_master`. The mnemonic / seed / master scalar all +//! live in `Zeroizing` buffers (the master's `private_key` is +//! explicitly `non_secure_erase`d — `ExtendedPrivKey` has no `Drop`) +//! and are scrubbed before this function returns. This is the path +//! the iOS app takes. If the resolver is null for such a wallet, the +//! call returns an error hinting that a mnemonic resolver handle is +//! required for this wallet shape. +//! //! Newly-discovered identities land in the wallet's `IdentityManager` //! and are forwarded to Swift via the existing persister callback //! (`on_persist_identities_fn`), so no extra SwiftData wiring is @@ -27,8 +58,11 @@ use platform_wallet::wallet::identity::network::IdentityDiscoveryOptions; use crate::check_ptr; use crate::error::*; use crate::handle::*; +use crate::identity_keys_from_mnemonic::resolve_master_from_resolver; use crate::runtime::block_on_worker; +use crate::types::Network; use crate::{unwrap_option_or_return, unwrap_result_or_return}; +use rs_sdk_ffi::MnemonicResolverHandle; /// Heap-allocated array of 32-byte identity ids returned by /// [`platform_wallet_discover_identities`]. Release by handing the @@ -56,8 +90,26 @@ impl DiscoveredIdentityIdsFFI { /// DIP-9 identity-authentication derivation tree and querying /// Platform for each derived MASTER pubkey hash. /// +/// The derivation source is chosen by the in-process wallet's +/// capability (see the module docs): resident-key wallets scan via the +/// in-process derive and never touch the resolver; external-signable / +/// watch-only wallets consult the resolver. The resolver is only +/// *needed* for the latter shape. +/// /// # Parameters /// - `wallet_handle` — platform-wallet handle. +/// - `mnemonic_resolver_handle` — Swift-owned +/// [`MnemonicResolverHandle`], consulted **only** when the in-process +/// wallet lacks resident private keys (external-signable / watch-only +/// — the iOS Keychain-backed `WalletType::ExternalSignable` shape +/// whose seed is not in process). For such a wallet, when non-null +/// the mnemonic is resolved on demand (keyed by the wallet handle's +/// own `wallet_id`), a master `ExtendedPrivKey` is built, and the scan +/// derives each probe hash from that master; when null the call errors +/// with a hint that a resolver handle is required for this wallet +/// shape. For a wallet that holds resident private keys this argument +/// is ignored and the scan derives from the in-process wallet (the +/// historical path). /// - `start_index_or_neg1` — `>= 0` starts from that explicit /// identity index; `< 0` resumes from the wallet's cached /// `last_scanned_index`. @@ -71,10 +123,14 @@ impl DiscoveredIdentityIdsFFI { /// /// # Safety /// `wallet_handle` must come from the platform-wallet handle -/// registry. `out_found` must be a valid, writable pointer. +/// registry. `mnemonic_resolver_handle`, when non-null, must come +/// from [`rs_sdk_ffi::dash_sdk_mnemonic_resolver_create`] and remain +/// valid for the duration of the call. `out_found` must be a valid, +/// writable pointer. #[no_mangle] pub unsafe extern "C" fn platform_wallet_discover_identities( wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, start_index_or_neg1: i64, gap_limit: u32, out_found: *mut DiscoveredIdentityIdsFFI, @@ -100,7 +156,97 @@ pub unsafe extern "C" fn platform_wallet_discover_identities( let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity = wallet.identity().clone(); - block_on_worker(async move { identity.discover(opts).await }) + + // Select the derivation source by the in-process wallet's + // capability (see the module docs), NOT by whether a resolver + // was supplied. Read the wallet's shape under a short read-lock + // and DROP the guard before `block_on_worker` — the scan future + // is `Send + 'static`, so the guard must not be held across it. + let wallet_has_resident_keys = { + let wm = wallet.wallet_manager().blocking_read(); + match wm.get_wallet(&wallet.wallet_id()) { + Some(key_wallet) => { + !key_wallet.is_external_signable() && !key_wallet.is_watch_only() + } + None => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Wallet not found in wallet manager", + )); + } + } + }; + + if wallet_has_resident_keys { + // Resident private keys (Mnemonic / Seed / ExtendedPrivKey) → + // historical in-process derive. The resolver is never touched + // (also skips a pointless iOS Keychain read), so raw-seed / + // mnemonic wallets keep working even when no mnemonic was ever + // persisted to `WalletStorage`. + return block_on_worker(async move { identity.discover(opts).await }) + .map_err(PlatformWalletFFIResult::from); + } + + // External-signable / watch-only wallet: its seed lives in iOS + // Keychain, not in process, so the resident derive would fail with + // `External signable wallet has no private key`. A resolver is + // required here. + if mnemonic_resolver_handle.is_null() { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "this wallet has no resident private keys (external-signable / \ + watch-only); a mnemonic resolver handle is required to scan for \ + its identities", + )); + } + + // Resolver path: resolve the wallet's mnemonic → build master + // xpriv → drive `discover_from_master`. Self-pin on the wallet + // handle's own `wallet_id` (same rationale as + // `dash_sdk_derive_identity_key_at_slot_with_resolver`): a + // separate wallet_id param would let a caller derive from one + // wallet's mnemonic while scanning a different wallet's handle. + let wallet_id = wallet.wallet_id(); + // `wallet.network()` returns `dashcore::Network`, which is the + // same type `ExtendedPrivKey::new_master` and the discovery scan + // derive with. + let network: Network = wallet.network(); + + // SAFETY: `mnemonic_resolver_handle` is non-null (checked above) + // and the caller's safety contract guarantees it came from + // `dash_sdk_mnemonic_resolver_create` and is valid for this call. + // Resolves the mnemonic + builds the master in one shared helper + // so the discovery and preview paths can't drift; the helper + // holds the mnemonic / seed in `Zeroizing` buffers and scrubs + // them before returning. The master's inner scalar is wiped by + // us below (`ExtendedPrivKey` has no `Drop`). + let master = match unsafe { + resolve_master_from_resolver(mnemonic_resolver_handle, &wallet_id, network) + } { + Ok(m) => m, + Err(e) => return Err(e), + }; + + // Run the scan against the resolved master. The master is MOVED + // into the spawned future: `block_on_worker` polls on a worker + // thread (the `'static` bound forbids borrowing our stack + // `master`), so we hand ownership in and wipe it INSIDE the + // future once `discover_from_master` is done deriving. + // + // `ExtendedPrivKey` has no `Drop` / `Zeroize`, so the inner + // secp256k1 scalar is scrubbed explicitly with + // `non_secure_erase` — same hygiene as + // `dash_sdk_sign_with_mnemonic_resolver_and_path` and + // `mnemonic_resolver_core_signer`. (`seed` / `mnemonic_buf` are + // `Zeroizing` and already scrubbed inside + // `resolve_master_from_resolver`.) + block_on_worker(async move { + let mut master = master; + let scan_result = identity.discover_from_master(opts, &master).await; + master.private_key.non_secure_erase(); + scan_result + }) + .map_err(PlatformWalletFFIResult::from) }); let result = unwrap_option_or_return!(option); let found = unwrap_result_or_return!(result); diff --git a/packages/rs-platform-wallet-ffi/src/identity_key_preview.rs b/packages/rs-platform-wallet-ffi/src/identity_key_preview.rs index d2969a65f2a..200fcad6525 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_key_preview.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_key_preview.rs @@ -25,18 +25,57 @@ //! //! The Swift caller knows nothing about any of those — it just reads //! the array back out. +//! +//! # Key source: chosen by wallet capability +//! +//! The derivation source is selected by the in-process wallet's shape, +//! NOT by whether a resolver handle was supplied — the resolver is a +//! *capability* the Rust side consults only when it can't derive +//! locally, not a command that forces the resolver path: +//! +//! - **In-process wallet holds resident private keys** (`WalletType:: +//! Mnemonic` / `Seed` / `ExtendedPrivKey` — NOT external-signable and +//! NOT watch-only): derive every row from the resident wallet under a +//! single read lock held for the loop's duration (the historical +//! path). The resolver handle is never touched, which also skips a +//! pointless iOS Keychain read. This keeps `createWallet(seed:)` / +//! raw-seed wallets working even when no BIP-39 mnemonic was ever +//! persisted to `WalletStorage`. +//! - **In-process wallet is external-signable / watch-only:** its seed +//! lives in iOS Keychain, NOT in process, so the resident derive +//! would fail with `External signable wallet has no private key`. In +//! that case, if `mnemonic_resolver_handle` is non-null, resolve the +//! wallet's mnemonic on demand via the Swift-owned +//! [`MnemonicResolverHandle`] (keyed by the wallet handle's own +//! `wallet_id`), build the master `ExtendedPrivKey`, and derive each +//! row from that master via +//! [`derive_ecdsa_identity_auth_keypair_from_master`] — the same +//! derive the rescan-via-resolver and the registration paths use. +//! The mnemonic / seed / master scalar live in `Zeroizing` buffers +//! (the master's `private_key` is explicitly `non_secure_erase`d — +//! `ExtendedPrivKey` has no `Drop`) and are scrubbed before this +//! function returns. This is the path the iOS app takes. If the +//! resolver is null for such a wallet, the call returns an error +//! hinting that a mnemonic resolver handle is required for this +//! wallet shape. use std::ffi::CString; use std::os::raw::c_char; use std::ptr; use dashcore::PrivateKey as DashPrivateKey; +use key_wallet::bip32::ExtendedPrivKey; +use key_wallet::Wallet; +use platform_wallet::wallet::identity::network::derive_ecdsa_identity_auth_keypair_from_master; use platform_wallet::{derive_identity_auth_keypair, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX}; +use zeroize::Zeroizing; use crate::error::*; use crate::handle::*; -use crate::identity_keys_from_mnemonic::zeroize_and_free_row; +use crate::identity_keys_from_mnemonic::{resolve_master_from_resolver, zeroize_and_free_row}; +use crate::types::Network; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use rs_sdk_ffi::MnemonicResolverHandle; /// One identity-registration-key preview row. /// @@ -123,8 +162,26 @@ impl IdentityKeyPreviewsFFI { /// keypairs this wallet would probe during a discovery scan, /// starting at identity index `start_index`. /// +/// The derivation source is chosen by the in-process wallet's +/// capability (see the module docs): resident-key wallets derive +/// locally and never touch the resolver; external-signable / watch-only +/// wallets consult the resolver. The resolver is only *needed* for the +/// latter shape. +/// /// # Parameters /// - `wallet_handle` — platform-wallet handle. +/// - `mnemonic_resolver_handle` — Swift-owned +/// [`MnemonicResolverHandle`], consulted **only** when the in-process +/// wallet lacks resident private keys (external-signable / watch-only +/// — the iOS Keychain-backed `WalletType::ExternalSignable` shape +/// whose seed is not in process). For such a wallet, when non-null +/// the mnemonic is resolved on demand (keyed by the wallet handle's +/// own `wallet_id`), a master `ExtendedPrivKey` is built, and each row +/// is derived from that master; when null the call errors with a hint +/// that a resolver handle is required for this wallet shape. For a +/// wallet that holds resident private keys this argument is ignored +/// and the rows are derived from the in-process wallet (the +/// historical path). /// - `start_index` — first identity index to derive. /// - `count_or_neg1` — number of consecutive identity indices to /// derive. Pass `< 0` to use the Rust default @@ -137,10 +194,14 @@ impl IdentityKeyPreviewsFFI { /// /// # Safety /// `wallet_handle` must come from the platform-wallet handle -/// registry. `out_previews` must be a valid, writable pointer. +/// registry. `mnemonic_resolver_handle`, when non-null, must come +/// from [`rs_sdk_ffi::dash_sdk_mnemonic_resolver_create`] and remain +/// valid for the duration of the call. `out_previews` must be a +/// valid, writable pointer. #[no_mangle] pub unsafe extern "C" fn platform_wallet_preview_identity_registration_keys( wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, start_index: u32, count_or_neg1: i32, out_previews: *mut IdentityKeyPreviewsFFI, @@ -164,77 +225,265 @@ pub unsafe extern "C" fn platform_wallet_preview_identity_registration_keys( } let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { - // Synchronous read of the wallet manager — FFI callers come - // in on non-tokio threads. - let wm = wallet.wallet_manager().blocking_read(); - let key_wallet = wm.get_wallet(&wallet.wallet_id()).ok_or_else(|| { - PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidHandle, - "Wallet not found in wallet manager", + // Resolve the network from the wallet handle. `wallet.network()` + // returns `dashcore::Network`, the type both `new_master` and the + // derive helpers use. + let network: Network = wallet.network(); + + // Per-row materials: (path string, secp256k1 public key bytes, + // 32-byte private scalar). Both key sources funnel through this + // so the row-building (WIF, pubkey buffer, zeroize-on-error) is + // written exactly once. + struct RowMaterial { + path: String, + public_key: [u8; 33], + private_key: Zeroizing<[u8; 32]>, + } + + // Build one heap-detached FFI row from already-derived material. + // All fallible work runs before any `into_raw` / `mem::forget` + // so an early `?` cleans up via Drop. + let build_row = |identity_index: u32, + material: RowMaterial| + -> Result { + let path_cstring = CString::new(material.path)?; + + // WIF: network-aware (mainnet → 0xCC, testnet/devnet/ + // regtest → 0xEF) and compressed. Same construction + // `key_wallet::derive_private_key_as_wif` performs. + let secret_key = dashcore::secp256k1::SecretKey::from_slice( + material.private_key.as_ref(), ) - })?; - let network = key_wallet.network; - - // Build a single row. All fallible work runs first; raw- - // pointer detachment (`into_raw`, `mem::forget`) happens at - // the very end so an early `?` cleans up via Drop. - let build_row = - |identity_index: u32| -> Result { - let (path, ext_priv, public_key) = derive_identity_auth_keypair( - key_wallet, - network, - identity_index, - MASTER_KEY_INDEX, - )?; - - let path_cstring = CString::new(path.to_string())?; - - // WIF: network-aware (mainnet → 0xCC, testnet/devnet/ - // regtest → 0xEF) and compressed. Same construction - // `key_wallet::derive_private_key_as_wif` performs. - let dash_private = DashPrivateKey { - compressed: true, - network, - inner: ext_priv.private_key, - }; - let wif_cstring = CString::new(dash_private.to_wif())?; - - // Compressed secp256k1 pubkey is always 33 bytes. - let pub_bytes: [u8; 33] = public_key.serialize(); - let mut pub_box: Box<[u8]> = pub_bytes.to_vec().into_boxed_slice(); - let pub_ptr = pub_box.as_mut_ptr(); - let pub_len = pub_box.len(); - std::mem::forget(pub_box); - - Ok(IdentityKeyPreviewFFI { - identity_index, - derivation_path: path_cstring.into_raw(), - public_key: pub_ptr, - public_key_len: pub_len, - private_key_wif: wif_cstring.into_raw(), - private_key_bytes: ext_priv.private_key.secret_bytes(), - }) + .map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("SecretKey::from_slice failed: {e}"), + ) + })?; + let dash_private = DashPrivateKey { + compressed: true, + network, + inner: secret_key, }; + let wif_cstring = CString::new(dash_private.to_wif())?; + + // Compressed secp256k1 pubkey is always 33 bytes. + let mut pub_box: Box<[u8]> = material.public_key.to_vec().into_boxed_slice(); + let pub_ptr = pub_box.as_mut_ptr(); + let pub_len = pub_box.len(); + std::mem::forget(pub_box); + + Ok(IdentityKeyPreviewFFI { + identity_index, + derivation_path: path_cstring.into_raw(), + public_key: pub_ptr, + public_key_len: pub_len, + private_key_wif: wif_cstring.into_raw(), + private_key_bytes: *material.private_key, + }) + }; + + // Borrowed per-row key source. The two derivation paths are + // symmetric in shape: both hand `derive_material` a borrowed + // source and get back `RowMaterial`. The resident-wallet + // variant borrows the `&Wallet` looked up under a read guard + // re-acquired for the loop's duration (the derive is pure + // compute, so holding it across the loop is fine — but the guard + // is NEVER held across the Swift resolver callback, see below); + // the master variant borrows the resolved master xpriv and needs + // no guard at all. + enum DeriveSource<'a> { + /// In-process wallet holds resident private keys — derive + /// each row directly from it (no per-row lock acquisition). + Resident(&'a Wallet), + /// External-signable / watch-only wallet — derive each row + /// from the master xpriv resolved from the wallet's mnemonic. + Master(&'a ExtendedPrivKey), + } - let mut rows: Vec = Vec::with_capacity(count as usize); - for offset in 0..count { - // Saturating add: the discovery scan caps identity - // indices well below u32::MAX in practice; if a caller - // intentionally passes near-max values we simply repeat - // the cap rather than wrap. - let identity_index = start_index.saturating_add(offset); - match build_row(identity_index) { - Ok(row) => rows.push(row), - Err(e) => { - // Free everything we've successfully appended so - // far — we never hand a partial array back. - // TODO: Implement Drop instead of manually drop so ? op is usable - free_rows(rows); - return Err(e); + // Derive one row's material from the active borrowed key source. + let derive_material = |identity_index: u32, + source: &DeriveSource| + -> Result { + match source { + DeriveSource::Master(master) => { + // External-signable / watch-only path: pure derive + // from the resolved master, identical to the + // registration / rescan-via-resolver derive. + let derived = derive_ecdsa_identity_auth_keypair_from_master( + master, + network, + identity_index, + MASTER_KEY_INDEX, + )?; + Ok(RowMaterial { + path: derived.derivation_path.to_string(), + public_key: derived.public_key, + private_key: derived.private_key, + }) + } + DeriveSource::Resident(key_wallet) => { + // Resident-wallet path: derive from the in-process + // wallet. The read guard + `&Wallet` were re-acquired + // once before the loop (see below) so this is a + // pure secp256k1 pass with no per-row locking. + let (path, ext_priv, public_key) = derive_identity_auth_keypair( + key_wallet, + network, + identity_index, + MASTER_KEY_INDEX, + )?; + Ok(RowMaterial { + path: path.to_string(), + public_key: public_key.serialize(), + private_key: Zeroizing::new(ext_priv.private_key.secret_bytes()), + }) } } - } - Ok(rows) + }; + + // Everything from here on can fail with a `PlatformWalletFFIResult`; + // run it in a closure returning `Result, _>`. + // + // Two-phase locking, mirroring the discovery path + // (`platform_wallet_discover_identities`): + // 1. A SHORT read-guard block scoped to the capability check + // only — read the wallet's shape, capture + // `wallet_has_resident_keys`, then DROP the guard. + // 2. The wallet-manager read guard is NEVER held across the + // Swift resolver callback (`resolve_master_from_resolver` + // synchronously re-enters Swift and reads the iOS Keychain, + // which can stall on biometric unlock) — invariant called + // out in review. + // 3. Only the resident branch re-acquires the guard, and only + // for the loop's duration (its `derive_material` borrows + // `&Wallet`). The master branch holds no guard past the + // capability check. + let build_result = (|| -> Result, PlatformWalletFFIResult> { + // Phase 1 — short capability-check guard. Read the wallet's + // shape under a read-lock and DROP it before any resolver + // interaction. Resident private keys (Mnemonic / Seed / + // ExtendedPrivKey) → historical in-process derive; the + // resolver is never touched (also skips a pointless iOS + // Keychain read). Otherwise the master xpriv resolved from + // the wallet's mnemonic is required. + let wallet_has_resident_keys = { + let wm = wallet.wallet_manager().blocking_read(); + match wm.get_wallet(&wallet.wallet_id()) { + Some(key_wallet) => { + !key_wallet.is_external_signable() && !key_wallet.is_watch_only() + } + None => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Wallet not found in wallet manager", + )); + } + } + }; + + // For the resolver path, resolve the mnemonic once and build + // the master xpriv up front (NO guard held — see the + // two-phase note above); the per-row derive then reuses it. + // Self-pin on the wallet handle's own `wallet_id` (same + // rationale as `dash_sdk_derive_identity_key_at_slot_with_resolver`). + // + // `master_opt` outlives `source` below (which borrows it), + // and its inner scalar is wiped just before this closure + // returns — see the `non_secure_erase` at the bottom. + let mut master_opt: Option = None; + if !wallet_has_resident_keys { + if mnemonic_resolver_handle.is_null() { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "this wallet has no resident private keys (external-signable / \ + watch-only); a mnemonic resolver handle is required to preview \ + its identity-registration keys", + )); + } + let wallet_id = wallet.wallet_id(); + // SAFETY: handle is non-null (checked) and the caller's + // safety contract guarantees it came from + // `dash_sdk_mnemonic_resolver_create`. + master_opt = Some(unsafe { + resolve_master_from_resolver(mnemonic_resolver_handle, &wallet_id, network)? + }); + } + + // Phase 3 — derive + build every row from the borrowed key + // source, inside a block so both the borrowed `source` and + // the resident-path read guard release at the block's end — + // BEFORE we wipe the resolved master's scalar below. The + // master branch needs no guard. The resident branch + // re-acquires the read guard and re-looks-up the `&Wallet`, + // both living through the loop via a guard binding so the + // borrow outlives `derive_material`'s calls. + // + // On any failure we free the rows appended so far and capture + // the error — we must still wipe the master's scalar below, + // so the loop result is captured rather than `?`-returned. + let loop_result = { + let mut loop_guard = None; + let source = match master_opt.as_ref() { + Some(master) => DeriveSource::Master(master), + None => { + let wm = loop_guard.insert(wallet.wallet_manager().blocking_read()); + let key_wallet = wm.get_wallet(&wallet.wallet_id()).ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Wallet not found in wallet manager", + ) + })?; + DeriveSource::Resident(key_wallet) + } + }; + + (|| -> Result, PlatformWalletFFIResult> { + let mut rows: Vec = Vec::with_capacity(count as usize); + for offset in 0..count { + // Saturating add: the discovery scan caps identity + // indices well below u32::MAX in practice; if a caller + // intentionally passes near-max values we simply repeat + // the cap rather than wrap. + let identity_index = start_index.saturating_add(offset); + let material = match derive_material(identity_index, &source) { + Ok(m) => m, + Err(e) => { + free_rows(rows); + return Err(e); + } + }; + match build_row(identity_index, material) { + Ok(row) => rows.push(row), + Err(e) => { + // Free everything we've successfully appended + // so far — we never hand a partial array back. + // TODO: Implement Drop instead of manually drop so ? op is usable + free_rows(rows); + return Err(e); + } + } + } + Ok(rows) + })() + // `source` (and `loop_guard`, the resident-path read + // guard) drop at this block's end, releasing the + // wallet-manager read lock held across the loop and the + // borrow into `master_opt` — so the master wipe below is + // free to mutate it. + }; + + // TODO(upstream): `ExtendedPrivKey` has no `Drop` / `Zeroize`; + // wipe the resolved master's inner secp256k1 scalar + // explicitly. Same hygiene as the discovery resolver path. + // No-op on the resident path (no master was resolved). + if let Some(mut master) = master_opt { + master.private_key.non_secure_erase(); + } + loop_result + })(); + + build_result }); let result = unwrap_option_or_return!(option); let rows = unwrap_result_or_return!(result); 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 a70c252e920..1f374f4bfda 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 @@ -75,6 +75,105 @@ pub(crate) fn parse_mnemonic_any_language(phrase: &str) -> Result Result { + 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 => { + return Err(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(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: mnemonic exceeded the FFI buffer capacity", + )); + } + _ => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: failed (other / Keychain access error)", + )); + } + } + if mnemonic_len == 0 || mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { + return Err(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| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + format!("mnemonic resolver: returned invalid UTF-8: {e}"), + ) + })?; + let mnemonic = parse_mnemonic_any_language(mnemonic_str).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("mnemonic resolver: returned an invalid mnemonic: {e}"), + ) + })?; + + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + drop(mnemonic); + + ExtendedPrivKey::new_master(network, seed.as_ref()).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("failed to build master xpriv from resolved mnemonic: {e}"), + ) + }) +} + /// Build the DIP-9 identity-authentication derivation path /// `m/9'/coin'/5'/0'/0'/identity_index'/key_index'`. pub(crate) fn identity_auth_derivation_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 4af4b3d7d3a..41369a3f72b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -2,11 +2,35 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; +use key_wallet::bip32::ExtendedPrivKey; use crate::error::PlatformWalletError; use super::*; +/// Where the per-index MASTER auth pubkey hash for the discovery scan +/// comes from. The two discovery entry points differ *only* in this: +/// everything else (gap-limit bookkeeping, Platform lookup, identity +/// folding, DPNS enrichment) is shared in [`IdentityWallet::discover_inner`]. +/// +/// - [`KeyHashSource::ResidentWallet`] derives from the in-process +/// `Wallet` key material (the historical path). Only valid for wallets +/// that actually hold a private key in memory +/// (`WalletType::Mnemonic` / `Seed` / `ExtendedPrivKey`). +/// - [`KeyHashSource::Master`] derives from a master `ExtendedPrivKey` +/// the caller resolved from the wallet's mnemonic on demand. This is +/// the path the iOS Keychain-backed `WalletType::ExternalSignable` +/// shape uses: its seed lives outside the in-process wallet manager, +/// so the resident-wallet derive would fail with +/// `External signable wallet has no private key`. +enum KeyHashSource<'a> { + /// Derive each probe hash from the in-memory wallet under a per-index + /// read lock on the shared `WalletManager`. + ResidentWallet, + /// Derive each probe hash from this master xpriv (pure, no lock). + Master(&'a ExtendedPrivKey), +} + // --------------------------------------------------------------------------- // Identity discovery (gap-limit scan) // --------------------------------------------------------------------------- @@ -80,7 +104,54 @@ impl IdentityWallet { &self, opts: IdentityDiscoveryOptions, ) -> Result, PlatformWalletError> { - use super::identity_handle::{identity_auth_derivation_path, MASTER_KEY_INDEX}; + self.discover_inner(opts, KeyHashSource::ResidentWallet) + .await + } + + /// Master-xpriv variant of [`Self::discover`]: run the identical + /// gap-limit scan / identity-folding / DPNS-enrichment logic, but + /// derive each probe's MASTER auth pubkey hash from the supplied + /// master `ExtendedPrivKey` instead of from the in-memory wallet. + /// + /// This is the path the iOS Keychain-backed + /// `WalletType::ExternalSignable` wallets must take: their seed lives + /// outside the in-process wallet manager, so [`Self::discover`]'s + /// resident-wallet derive fails with + /// `External signable wallet has no private key`. The caller resolves + /// the wallet's mnemonic into `master` on demand (see the FFI + /// resolver path) and hands it in here; the derivation goes through + /// the same [`derive_identity_auth_key_hash_from_master`] the + /// registration path uses, so a rescan derives exactly 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(""))`). + pub async fn discover_from_master( + &self, + opts: IdentityDiscoveryOptions, + master: &ExtendedPrivKey, + ) -> Result, PlatformWalletError> { + self.discover_inner(opts, KeyHashSource::Master(master)) + .await + } + + /// Shared gap-limit scan body for [`Self::discover`] and + /// [`Self::discover_from_master`]. The only thing the two callers + /// vary is `source`, which decides how each probe's MASTER auth + /// pubkey hash is derived (in-memory wallet under a per-index read + /// lock, vs. a resolved master xpriv). Everything downstream — the + /// Platform unique-hash lookup, identity folding, derivation + /// breadcrumb, and DPNS enrichment — is identical, so it lives here + /// once. + async fn discover_inner( + &self, + opts: IdentityDiscoveryOptions, + source: KeyHashSource<'_>, + ) -> Result, PlatformWalletError> { + use super::identity_handle::{ + derive_identity_auth_key_hash_from_master, identity_auth_derivation_path, + MASTER_KEY_INDEX, + }; use crate::wallet::identity::state::managed_identity::key_storage::DpnsNameInfo; use crate::wallet::identity::state::managed_identity::key_storage::IdentityStatus; use dash_sdk::platform::types::identity::PublicKeyHash; @@ -122,14 +193,32 @@ impl IdentityWallet { let mut discovered: Vec = Vec::new(); while consecutive_misses < gap_limit { - let key_hash_array = { - let wm = self.wallet_manager.read().await; - let wallet = wm.get_wallet(&self.wallet_id).ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound( - "Wallet not found in wallet manager".to_string(), - ) - })?; - derive_identity_auth_key_hash(wallet, network, identity_index, MASTER_KEY_INDEX)? + // Derive the MASTER auth pubkey hash for this identity index + // from whichever source the caller picked. The per-index read + // lock is only needed for the wallet-internal derive (it reads + // the resident key material); the master derive is a pure, + // lock-free secp256k1 pass. + let key_hash_array = match source { + KeyHashSource::ResidentWallet => { + let wm = self.wallet_manager.read().await; + let wallet = wm.get_wallet(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet not found in wallet manager".to_string(), + ) + })?; + derive_identity_auth_key_hash( + wallet, + network, + identity_index, + MASTER_KEY_INDEX, + )? + } + KeyHashSource::Master(master) => derive_identity_auth_key_hash_from_master( + master, + network, + identity_index, + MASTER_KEY_INDEX, + )?, }; // Query Platform for an identity registered with this key diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 71ffaa6c149..881a9a73890 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -221,6 +221,13 @@ pub fn derive_identity_auth_keypair( /// Thin wrapper over [`derive_identity_auth_keypair`] — shares the /// path-building + derivation so the FFI-side preview and the live /// scan can never drift from one another. +/// +/// Requires a [`Wallet`] with resident private key material. For +/// wallets whose seed lives outside the in-process wallet manager +/// (`WalletType::ExternalSignable` — the iOS Keychain-backed shape), +/// this errors with `External signable wallet has no private key`; use +/// [`derive_identity_auth_key_hash_from_master`] instead, fed by a +/// master xpriv the caller resolved from the mnemonic on demand. pub(crate) fn derive_identity_auth_key_hash( wallet: &Wallet, network: key_wallet::Network, @@ -240,6 +247,41 @@ pub(crate) fn derive_identity_auth_key_hash( Ok(key_hash_array) } +/// Master-xpriv sibling of [`derive_identity_auth_key_hash`]: derive the +/// 20-byte RIPEMD160(SHA256) pubkey hash for the identity-authentication +/// slot `(identity_index, key_index)` directly from a master +/// `ExtendedPrivKey` instead of from an in-memory [`Wallet`]. +/// +/// Pure function — no `Wallet` required — so it works for the +/// `WalletType::ExternalSignable` shape where the seed lives outside the +/// in-process wallet manager (iOS Keychain). The caller resolves the +/// mnemonic into a master xpriv on demand (see the FFI resolver path) +/// and hands it in here. +/// +/// Goes through [`derive_ecdsa_identity_auth_keypair_from_master`] so the +/// rescan scan, the registration derive path, and the in-creation key #0 +/// can never drift on the path shape / secp256k1 derive: it derives the +/// same compressed pubkey the wallet-internal +/// [`derive_identity_auth_key_hash`] would for a key-resident wallet at +/// the same slot, then `ripemd160_sha256`-hashes it identically. +pub fn derive_identity_auth_key_hash_from_master( + master: &ExtendedPrivKey, + network: key_wallet::Network, + identity_index: u32, + key_index: u32, +) -> Result<[u8; 20], PlatformWalletError> { + use dpp::util::hash::ripemd160_sha256; + + let derived = + derive_ecdsa_identity_auth_keypair_from_master(master, network, identity_index, key_index)?; + let key_hash = ripemd160_sha256(&derived.public_key); + + let mut key_hash_array = [0u8; 20]; + key_hash_array.copy_from_slice(&key_hash); + + Ok(key_hash_array) +} + /// Identity + DashPay wallet facade. /// /// A view onto the shared `PlatformWalletInfo` state inside the wallet @@ -466,3 +508,174 @@ impl IdentityWallet { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::util::hash::ripemd160_sha256; + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + use key_wallet::Network; + + /// English BIP-39 test vector (all-zero entropy). Same fixture the + /// FFI-side derive tests use, so the derivations here can be + /// cross-checked against those if a regression ever appears on one + /// side only. + const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + /// Build a key-resident `WalletType::Mnemonic` wallet on `network` + /// from [`TEST_MNEMONIC`]. `WalletAccountCreationOptions::None` + /// skips the BLS/EdDSA provider accounts the discovery scan never + /// touches — the identity-auth derivation walks the master xpriv, + /// not the per-account collection, so no accounts are needed. + fn mnemonic_wallet(network: Network) -> Wallet { + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid English test mnemonic"); + Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::None) + .expect("from_mnemonic should build a Mnemonic wallet") + } + + /// The BIP-32 master node for [`TEST_MNEMONIC`] on `network` — the + /// same node `derive_extended_private_key` reconstructs internally + /// (`RootExtendedPrivKey::new_master(seed).to_extended_priv_key(network)` + /// is byte-for-byte `ExtendedPrivKey::new_master(network, seed)`). + fn master_for(network: Network) -> ExtendedPrivKey { + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid English test mnemonic"); + let seed = mnemonic.to_seed(""); + ExtendedPrivKey::new_master(network, &seed).expect("master xpriv from test seed") + } + + /// (a) The master-based key-hash helper must produce the SAME 20-byte + /// hash as the wallet-internal `derive_identity_auth_key_hash` on a + /// key-resident `WalletType::Mnemonic` wallet, for every slot the + /// rescan probes. This is the core correctness guarantee: a rescan + /// driven through the resolved master derives exactly what a + /// key-resident wallet derives — same identity, same on-chain + /// pubkey-hash lookup. + #[test] + fn master_hash_matches_resident_wallet_hash() { + for network in [Network::Mainnet, Network::Testnet] { + let wallet = mnemonic_wallet(network); + let master = master_for(network); + + // Walk a window matching the discovery scan's MASTER slot + // across several identity indices. + for identity_index in 0..6u32 { + let resident = derive_identity_auth_key_hash( + &wallet, + network, + identity_index, + MASTER_KEY_INDEX, + ) + .expect("resident-wallet derive should succeed for a Mnemonic wallet"); + + let from_master = derive_identity_auth_key_hash_from_master( + &master, + network, + identity_index, + MASTER_KEY_INDEX, + ) + .expect("master derive should succeed"); + + assert_eq!( + resident, from_master, + "master-based hash must equal resident-wallet hash \ + (network={network:?}, identity_index={identity_index})" + ); + } + + // Non-MASTER key indices must also agree — the helper is the + // generic per-slot derive, not MASTER-pinned. + let resident = derive_identity_auth_key_hash(&wallet, network, 3, 7) + .expect("resident derive at (3,7)"); + let from_master = derive_identity_auth_key_hash_from_master(&master, network, 3, 7) + .expect("master derive at (3,7)"); + assert_eq!(resident, from_master, "non-MASTER slot must also agree"); + } + } + + /// (b) Pin the bug and its fix: `derive_identity_auth_key_hash` on a + /// `WalletType::ExternalSignable` wallet ERRORS (the seed lives + /// outside the in-process wallet — this is the exact failure the + /// rescan UI surfaced), while the master-based helper SUCCEEDS for + /// the same slot and yields the same hash a key-resident wallet + /// would. Mirrors how registration derives on these wallets. + #[test] + fn external_signable_errors_but_master_succeeds() { + let network = Network::Testnet; + + // Reference hash from a key-resident wallet at the probed slot. + let resident_wallet = mnemonic_wallet(network); + let expected = + derive_identity_auth_key_hash(&resident_wallet, network, 0, MASTER_KEY_INDEX) + .expect("resident derive should succeed"); + + // Downgrade a clone to ExternalSignable: same wallet id, but the + // key material is dropped — exactly the iOS Keychain-backed shape + // loaded into the in-process `WalletManager`. + let mut external = mnemonic_wallet(network); + external.downgrade_to_external_signable(); + + let resident_err = derive_identity_auth_key_hash(&external, network, 0, MASTER_KEY_INDEX); + let err = resident_err + .expect_err("ExternalSignable wallet has no resident key — derive must error"); + let msg = err.to_string(); + assert!( + msg.contains("External signable wallet has no private key"), + "error should be the External-signable no-private-key failure, got: {msg}" + ); + + // The master-based helper succeeds for the same slot and matches + // the key-resident reference hash — this is the rescan fix. + let master = master_for(network); + let from_master = + derive_identity_auth_key_hash_from_master(&master, network, 0, MASTER_KEY_INDEX) + .expect("master derive must succeed where the resident derive failed"); + assert_eq!( + from_master, expected, + "master derive on an ExternalSignable wallet must reproduce the \ + key-resident hash for the same slot" + ); + } + + /// (c) Parity with registration's in-creation key #0: the + /// master-based hash at `(identity_index, MASTER_KEY_INDEX)` must + /// equal `ripemd160_sha256` of the pubkey + /// `derive_ecdsa_identity_auth_keypair_from_master` produces at the + /// same slot — i.e. the rescan probes the hash of the very key + /// registration publishes as MASTER auth key #0. + #[test] + fn master_hash_matches_registration_keypair_pubkey_hash() { + let network = Network::Testnet; + let master = master_for(network); + + for identity_index in 0..4u32 { + let keypair = derive_ecdsa_identity_auth_keypair_from_master( + &master, + network, + identity_index, + MASTER_KEY_INDEX, + ) + .expect("registration-shaped keypair derive should succeed"); + let expected = ripemd160_sha256(&keypair.public_key); + + let hash = derive_identity_auth_key_hash_from_master( + &master, + network, + identity_index, + MASTER_KEY_INDEX, + ) + .expect("master hash derive should succeed"); + + assert_eq!( + hash.as_slice(), + expected.as_slice(), + "rescan hash must be ripemd160_sha256 of the registration \ + keypair's pubkey (identity_index={identity_index})" + ); + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index bea74f87882..b4035d3c26d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -46,9 +46,9 @@ mod tokens; pub use discovery::IdentityDiscoveryOptions; pub use dpns::{ContestContender, ContestVoteState, ContestWinner}; pub use identity_handle::{ - derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_keypair, - identity_auth_derivation_path_for_type, DerivedIdentityAuthKey, IdentityWallet, - IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, + derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash_from_master, + derive_identity_auth_keypair, identity_auth_derivation_path_for_type, DerivedIdentityAuthKey, + IdentityWallet, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; // Helpers declared on `identity_handle.rs` that siblings reach diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index bd7b5cb929b..47e4f135d58 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -749,12 +749,34 @@ extension ManagedPlatformWallet { /// Pass `nil` to defer to the Rust default /// (`IDENTITY_GAP_LIMIT`, currently 5) so the preview /// aligns with the scan window `discoverIdentities` walks. + /// - storage: `WalletStorage` instance used by the resolver + /// callback to read the BIP-39 mnemonic from iOS Keychain. + /// Defaults to a fresh `WalletStorage()` — overridable for + /// tests. /// /// - Throws: `PlatformWalletError` if the wallet handle is /// invalid or Rust-side derivation fails. + /// + /// # Key source: chosen by wallet capability (Rust-side) + /// + /// A [`MnemonicResolver`] is always passed, but Rust decides whether + /// to use it based on the in-process wallet's shape — it's a + /// *capability*, not a command. For wallets that hold resident + /// private keys (e.g. created from a raw seed via + /// `createWallet(seed:)`, or whose mnemonic was never persisted to + /// `WalletStorage`), Rust derives the preview rows from the + /// in-process wallet and never consults the resolver. The resolver + /// is consulted only when the in-process wallet lacks resident keys + /// (the iOS Keychain-backed `ExternalSignable` shape whose seed + /// lives in Keychain, not in the `WalletManager`): Rust resolves the + /// mnemonic on demand (keyed by this wallet's own `walletId`) and + /// derives the rows from it — the same mechanism the scan and + /// registration use. The local `resolver` is pinned across the + /// synchronous FFI call with `withExtendedLifetime`. public func previewIdentityRegistrationKeys( startIndex: UInt32 = 0, - count: UInt32? = nil + count: UInt32? = nil, + storage: WalletStorage = WalletStorage() ) throws -> [IdentityRegistrationKeyPreview] { // `-1` tells Rust to pick the crate-level IDENTITY_GAP_LIMIT // default. Any supplied value passes through as-is, clamped @@ -767,59 +789,73 @@ extension ManagedPlatformWallet { countOrNeg1 = -1 } - var out = IdentityKeyPreviewsFFI() - let result = platform_wallet_preview_identity_registration_keys( - handle, - startIndex, - countOrNeg1, - &out - ) - // Free the Rust-owned array whether we succeeded or bailed - // out — the free function is a no-op on the zero struct. - defer { platform_wallet_preview_identity_registration_keys_free(&out) } - - try result.check() + // The resolver reads the mnemonic from iOS Keychain on demand, + // pinned by Rust to this wallet handle's own `walletId`. Its FFI + // ctx is a `passUnretained` pointer (see the type's "Lifetime + // contract"), and Swift object lifetimes end at last use — not + // at scope end — so the last use of `resolver` (evaluating + // `resolver.handle` as an argument) would otherwise let ARC + // deallocate it while Rust is still mid-call, dangling the ctx. + // `withExtendedLifetime` pins it for the whole synchronous FFI + // call + result marshalling. Same shape as `discoverIdentities`. + let resolver = MnemonicResolver(storage: storage) - guard let base = out.items, out.count > 0 else { - return [] - } + return try withExtendedLifetime(resolver) { + var out = IdentityKeyPreviewsFFI() + let result = platform_wallet_preview_identity_registration_keys( + handle, + resolver.handle, + startIndex, + countOrNeg1, + &out + ) + // Free the Rust-owned array whether we succeeded or bailed + // out — the free function is a no-op on the zero struct. + defer { platform_wallet_preview_identity_registration_keys_free(&out) } - var previews: [IdentityRegistrationKeyPreview] = [] - previews.reserveCapacity(Int(out.count)) - for i in 0.. 0 { - pubData = Data(bytes: pubPtr, count: Int(row.public_key_len)) - pubHex = pubData.map { String(format: "%02x", $0) }.joined() - } else { - pubData = Data() - pubHex = "" + guard let base = out.items, out.count > 0 else { + return [] } - // Inline 32-byte tuple → owned `Data`. We copy because - // the underlying tuple is freed when the FFI struct is - // released by the deferred free call. - var pkTuple = row.private_key_bytes - let pkData = withUnsafeBytes(of: &pkTuple) { Data($0) } - - previews.append( - IdentityRegistrationKeyPreview( - identityIndex: row.identity_index, - derivationPath: path, - publicKeyData: pubData, - publicKeyHex: pubHex, - privateKeyWIF: wif, - privateKeyData: pkData + var previews: [IdentityRegistrationKeyPreview] = [] + previews.reserveCapacity(Int(out.count)) + for i in 0.. 0 { + pubData = Data(bytes: pubPtr, count: Int(row.public_key_len)) + pubHex = pubData.map { String(format: "%02x", $0) }.joined() + } else { + pubData = Data() + pubHex = "" + } + + // Inline 32-byte tuple → owned `Data`. We copy because + // the underlying tuple is freed when the FFI struct is + // released by the deferred free call. + var pkTuple = row.private_key_bytes + let pkData = withUnsafeBytes(of: &pkTuple) { Data($0) } + + previews.append( + IdentityRegistrationKeyPreview( + identityIndex: row.identity_index, + derivationPath: path, + publicKeyData: pubData, + publicKeyHex: pubHex, + privateKeyWIF: wif, + privateKeyData: pkData + ) ) - ) + } + return previews } - return previews } /// Derive a single ECDSA identity-authentication keypair at an @@ -1138,38 +1174,73 @@ extension ManagedPlatformWallet { /// - gapLimit: Maximum consecutive empty identity indices to /// tolerate before stopping. Defaults to the Rust default /// (`IDENTITY_GAP_LIMIT`, currently 5) when omitted. + /// - storage: `WalletStorage` instance used by the resolver + /// callback to read the BIP-39 mnemonic from iOS Keychain. + /// Defaults to a fresh `WalletStorage()` — overridable for + /// tests. /// - Returns: The identifiers of any identities the scan /// discovered that weren't already in the local manager. /// Identities already tracked are not re-reported. + /// + /// # Key source: chosen by wallet capability (Rust-side) + /// + /// A [`MnemonicResolver`] is always passed to the FFI, but Rust + /// decides whether to use it based on the in-process wallet's shape + /// — it's a *capability*, not a command. iOS Keychain-backed + /// `ExternalSignable` wallets keep their seed in Keychain, not in + /// the `WalletManager`, so the resident derive would fail with + /// `External signable wallet has no private key`; for those, Rust + /// resolves the mnemonic on demand (keyed by this wallet's own + /// `walletId`) and derives the scan keys from it — the same + /// mechanism identity registration uses. The resolver is consulted + /// only when the in-process wallet lacks resident keys: wallets that + /// hold resident private keys (e.g. created from a raw seed via + /// `createWallet(seed:)`, or whose mnemonic was never persisted to + /// `WalletStorage`) keep scanning via the in-process derive and + /// never touch the resolver. No mnemonic / derivation pipeline runs + /// in Swift; this stays a thin bridge per `swift-sdk/CLAUDE.md`. public func discoverIdentities( startIndex: UInt32? = nil, - gapLimit: UInt32? = nil + gapLimit: UInt32? = nil, + storage: WalletStorage = WalletStorage() ) async throws -> [Identifier] { let handle = self.handle let startArg: Int64 = startIndex.map(Int64.init) ?? -1 let gapArg: UInt32 = gapLimit ?? 0 + // The resolver reads the mnemonic from iOS Keychain on demand; + // Rust pins it to this wallet handle's own `walletId`, so no + // wallet-id argument is passed. `MnemonicResolver` is + // `@unchecked Sendable`; capture it in the detached closure and + // wrap the FFI call in `withExtendedLifetime` so ARC keeps it + // alive for the synchronous call's duration (its FFI ctx is a + // `passUnretained` pointer — see the type's "Lifetime + // contract"). + let resolver = MnemonicResolver(storage: storage) return try await Task.detached(priority: .userInitiated) { () -> [Identifier] in - var found = DiscoveredIdentityIdsFFI() - let result = platform_wallet_discover_identities( - handle, - startArg, - gapArg, - &found - ) - defer { platform_wallet_discover_identities_free(&found) } - try result.check() - guard let base = found.ids, found.count > 0 else { - return [] - } - var ids: [Identifier] = [] - ids.reserveCapacity(Int(found.count)) - for i in 0.. 0 else { + return [] + } + var ids: [Identifier] = [] + ids.reserveCapacity(Int(found.count)) + for i in 0..