diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 29dbb70442d..67c4b71bd5b 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -981,6 +981,152 @@ pub unsafe extern "C" fn platform_wallet_verify_seed_binds_to_wallet( } } +/// Marker-aware variant of [`platform_wallet_verify_seed_binds_to_wallet`]. +/// The binding check is a pure function of (mnemonic, network) against the +/// wallet's persisted account-0 xpub, so one successful verify holds for +/// every later launch **as long as the mnemonic Keychain item is untouched**. +/// The host passes `keychain_stamp` — an opaque identity/generation stamp of +/// that item (e.g. its creation+modification dates) that changes on every +/// write to it — and Rust binds the marker to BOTH the xpub and the stamp. +/// A marker that still matches proves the previously verified item is the +/// one in the Keychain and skips the resolver entirely — no mnemonic fetch, +/// no derivation. A rewritten mnemonic item, a re-imported wallet, or a +/// missing stamp all fall through to the full resolver check. +/// +/// Contract: `*out_marker` is set to a heap C string (free with +/// [`platform_wallet_string_free`](crate::platform_wallet_string_free)) ONLY +/// when the full check ran, bound, and a stamp was supplied — that is the +/// host's signal to persist the new marker. It stays null when the supplied +/// marker matched (nothing to persist) and when no stamp was supplied +/// (nothing safe to cache; the host keeps verifying every launch). A +/// mismatched derivation fails with `ErrorInvalidParameter` exactly like the +/// non-cached variant, leaving `*out_marker` null. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`; ownership is retained by the caller. +/// - `verified_marker` is **optional**: null means "no marker persisted yet" +/// (first launch); otherwise it must be a valid NUL-terminated UTF-8 C +/// string. +/// - `keychain_stamp` is **optional**: null means "stamp unavailable" and +/// disables the cache for this call; otherwise it must be a valid +/// NUL-terminated UTF-8 C string. +/// - `out_marker` must be a valid `*mut *mut c_char`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_verify_seed_binds_to_wallet_cached( + wallet_handle: Handle, + core_signer_handle: *mut MnemonicResolverHandle, + verified_marker: *const c_char, + keychain_stamp: *const c_char, + out_marker: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); + check_ptr!(out_marker); + // Zero-init the out-param before any fallible work so an early return + // leaves a safe null rather than uninitialized memory the caller might + // free — same discipline as the QR / profile getters. + unsafe { *out_marker = std::ptr::null_mut() }; + + let marker: Option = if verified_marker.is_null() { + None + } else { + Some( + unwrap_result_or_return!(unsafe { CStr::from_ptr(verified_marker) }.to_str()) + .to_string(), + ) + }; + let stamp: Option = if keychain_stamp.is_null() { + None + } else { + Some( + unwrap_result_or_return!(unsafe { CStr::from_ptr(keychain_stamp) }.to_str()) + .to_string(), + ) + }; + let signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: same lifetime contract as the drain FFI — the caller pins the + // resolver handle for the duration of this call. + let provider = unsafe { + resolver_contact_crypto_provider( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + let wallet = wallet.clone(); + block_on_worker(async move { + wallet + .verify_seed_binds_with_marker(&provider, marker.as_deref(), stamp.as_deref()) + .await + }) + }); + let result = unwrap_option_or_return!(option); + match result { + Ok((platform_wallet::SeedBindingVerification::MarkerMatched, _)) => { + PlatformWalletFFIResult::ok() + } + Ok((platform_wallet::SeedBindingVerification::Verified, new_marker)) => { + if let Some(new_marker) = new_marker { + let c_marker = match std::ffi::CString::new(new_marker) { + Ok(c) => c, + Err(_) => { + return PlatformWalletFFIResult::from( + "seed-binding marker contained an interior NUL".to_string(), + ) + } + }; + unsafe { + *out_marker = c_marker.into_raw(); + } + } + PlatformWalletFFIResult::ok() + } + Err(e @ platform_wallet::PlatformWalletError::SeedMismatch { .. }) => { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e.to_string(), + ) + } + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + e.to_string(), + ), + } +} + +/// Total number of queued contact-crypto entries for this wallet — every op +/// kind, **including** the `ContactInfoDecrypt` refreshes that +/// [`platform_wallet_pending_contact_crypto_count`] deliberately excludes. +/// This is the drain scheduler's probe: a signer-present drain should run +/// whenever anything at all is queued, or ContactInfoDecrypt-only queues +/// (cross-device contact metadata) would never be applied. Keep using the +/// filtered count for user-facing "waiting to finish setup" surfaces. Writes +/// the count to `out_count`. Signerless read; safe to poll. +/// +/// # Safety +/// - `out_count` must be a valid `*mut u32`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_drainable_contact_crypto_count( + wallet_handle: Handle, + out_count: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(out_count); + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.dashpay().drainable_contact_crypto_count().await }) + }); + let count = unwrap_option_or_return!(option); + unsafe { + *out_count = count as u32; + } + PlatformWalletFFIResult::ok() +} + #[cfg(test)] mod tests { use super::*; @@ -1008,6 +1154,66 @@ mod tests { assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); } + /// The cached variant rejects a null `core_signer_handle` with + /// `ErrorNullPointer` before any wallet lookup — same `check_ptr!` + /// contract as the non-cached entry point. + #[test] + fn verify_seed_binds_cached_null_signer_is_null_pointer() { + let mut out_marker: *mut c_char = std::ptr::null_mut(); + let r = unsafe { + platform_wallet_verify_seed_binds_to_wallet_cached( + 1, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + &mut out_marker, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!(out_marker.is_null()); + } + + /// The cached variant rejects a null `out_marker` with `ErrorNullPointer`. + #[test] + fn verify_seed_binds_cached_null_out_marker_is_null_pointer() { + let dummy_signer = std::ptr::dangling_mut::(); + let r = unsafe { + platform_wallet_verify_seed_binds_to_wallet_cached( + 1, + dummy_signer, + std::ptr::null(), + std::ptr::null(), + std::ptr::null_mut(), + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// An unknown `wallet_handle` surfaces `NotFound` via the `with_item` + /// lookup miss, and `*out_marker` is left at the zero-initialized null. + /// A null `verified_marker` is the legitimate "no marker yet" input, so + /// this also covers that path's marshalling. The signer handle is never + /// dereferenced (the wallet lookup fails first). + #[test] + fn verify_seed_binds_cached_unknown_wallet_is_not_found() { + let dummy_signer = std::ptr::dangling_mut::(); + let mut out_marker: *mut c_char = std::ptr::dangling_mut(); + let r = unsafe { + platform_wallet_verify_seed_binds_to_wallet_cached( + 0xDEAD_BEEF, + dummy_signer, + std::ptr::null(), + std::ptr::null(), + &mut out_marker, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert!( + out_marker.is_null(), + "out_marker is zero-initialized before the lookup" + ); + } + /// A null `out_count` is rejected with `ErrorNullPointer` (the `check_ptr!` /// contract) before any wallet lookup. #[test] diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index f936b5aa68d..209c02de69e 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -64,7 +64,8 @@ pub use wallet::core::WalletBalance; pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, - ContactInfoPublishOutcome, ContactInfoSealed, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, + ContactInfoPublishOutcome, ContactInfoSealed, SeedBindingVerification, IDENTITY_GAP_LIMIT, + MASTER_KEY_INDEX, }; pub use wallet::identity::{ calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 0b5c6221149..5ab18f94d0a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -1782,6 +1782,26 @@ impl DashPayView<'_, B> { .unwrap_or(0) } + /// Total number of queued contact-crypto entries for this wallet — every + /// op kind, **including** the `ContactInfoDecrypt` refreshes that + /// [`Self::pending_contact_crypto_count`] deliberately excludes. This is + /// the "does a signer-present drain have any work at all" probe: unlike + /// the banner count (which reports an actionable *backlog* to a user), + /// a scheduler should run the drain whenever anything is queued, or + /// ContactInfoDecrypt-only queues would never be applied. Signerless / + /// public read; no persistence. + pub async fn drainable_contact_crypto_count(&self) -> usize { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .map(|info| { + info.identity_manager + .managed_identities() + .map(|m| m.dashpay().pending_contact_crypto.len()) + .sum::() + }) + .unwrap_or(0) + } + /// Drain the persisted deferred-crypto queue using `provider` for the /// Keychain-derived key material. Call when a signer is available (Keychain /// unlock, or any signer-present DashPay action). Returns the number of 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 fb3615934b0..9c283873cdb 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -54,6 +54,7 @@ pub(crate) use payments::{ mod profile; pub(crate) mod sdk_writer; mod seed_binding; +pub use seed_binding::SeedBindingVerification; // Token state-transition operations (same `IdentityWallet` impl blocks). // Bookkeeping (watch / sync / balance) lives on diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs index c3345e261d5..8ba700b191c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs @@ -14,6 +14,22 @@ use crate::error::PlatformWalletError; use crate::wallet::identity::network::contact_requests::ContactCryptoProvider; use crate::wallet::platform_wallet::PlatformWallet; +/// How [`PlatformWallet::verify_seed_binds_with_marker`] established the +/// seed binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeedBindingVerification { + /// The host's persisted verified-binding marker equals the wallet's + /// current account-0 xpub combined with the current Keychain-item stamp — + /// the binding was proven on an earlier launch and neither the xpub nor + /// the mnemonic item has changed since, so the signer was not consulted. + MarkerMatched, + /// The full check ran: the account-0 xpub was derived through the signer + /// and matched the persisted one. The host should persist the returned + /// marker (when one is handed back) so later launches can skip the + /// derivation. + Verified, +} + impl PlatformWallet { /// Verify the signer behind `crypto` resolves the seed that owns this wallet. /// @@ -31,6 +47,36 @@ impl PlatformWallet { &self, crypto: &C, ) -> Result<(), PlatformWalletError> { + self.verify_seed_binds_with_marker(crypto, None, None) + .await + .map(|_| ()) + } + + /// Marker-aware variant of [`Self::verify_seed_binds`]: the derivation is a + /// pure function of (seed, network) against a fixed persisted xpub, so its + /// outcome cannot change between launches *while the underlying Keychain + /// mnemonic item is untouched*. After one successful verify the host + /// persists the returned marker and passes it back on later launches + /// together with `keychain_stamp`, an opaque identity/generation stamp of + /// the mnemonic Keychain item (e.g. its creation+modification dates) that + /// changes on every write to that item. The marker binds BOTH the wallet's + /// account-0 xpub AND the stamp, so a match proves the previously verified + /// mnemonic item is still the one in the Keychain — only then is the + /// signer skipped (no mnemonic resolution, no derivation). A rewritten or + /// re-created mnemonic item changes the stamp and falls through to the + /// full signer check, exactly like a first launch or wallet re-import. + /// + /// Returns the outcome plus the marker to persist: `Some` only when a + /// full verification ran, bound, and a stamp was supplied (without a + /// stamp there is nothing safe to cache — the caller keeps verifying + /// every launch). Errors exactly like [`Self::verify_seed_binds`] when + /// the full check runs and fails. + pub async fn verify_seed_binds_with_marker( + &self, + crypto: &C, + marker: Option<&str>, + keychain_stamp: Option<&str>, + ) -> Result<(SeedBindingVerification, Option), PlatformWalletError> { // Read the binding xpub and its exact derivation path from the same // account, so the two can never drift. Drop the lock before awaiting the // signer — the guard is not held across `.await`. @@ -49,9 +95,19 @@ impl PlatformWallet { (path, account.account_xpub) }; + // The only marker that can match is the one for the CURRENT xpub and + // the CURRENT Keychain item stamp. No stamp → no candidate → the full + // check below always runs (fail-safe: cache disabled, not bypassed). + let candidate = keychain_stamp.map(|stamp| format!("{expected}|{stamp}")); + if let (Some(marker), Some(candidate)) = (marker, candidate.as_deref()) { + if marker == candidate { + return Ok((SeedBindingVerification::MarkerMatched, None)); + } + } + let derived = crypto.receiving_xpub(&path).await?; if derived == expected { - Ok(()) + Ok((SeedBindingVerification::Verified, candidate)) } else { Err(PlatformWalletError::SeedMismatch { wallet_id: hex::encode(self.wallet_id()), @@ -62,6 +118,7 @@ impl PlatformWallet { #[cfg(test)] mod tests { + use super::SeedBindingVerification; use std::sync::Arc; use key_wallet::mnemonic::{Language, Mnemonic}; @@ -180,6 +237,242 @@ mod tests { ); } + /// First launch (no marker): the full signer check runs, binds, and + /// returns `Verified` plus the marker for the host to persist — the + /// wallet's account-0 xpub bound to the supplied Keychain-item stamp. + #[tokio::test] + async fn verify_with_no_marker_runs_full_check_and_returns_marker() { + let manager = make_manager(); + let network = Network::Testnet; + let seed = seed_for(TEST_MNEMONIC); + + let wallet = manager + .create_wallet_from_seed_bytes( + network, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet creation"); + + let crypto = SeedCryptoProvider::from_seed(seed, network); + let (outcome, marker) = wallet + .verify_seed_binds_with_marker(&crypto, None, Some("stamp-1")) + .await + .expect("the wallet's own seed must bind"); + assert_eq!(outcome, SeedBindingVerification::Verified); + + let expected_xpub = wallet + .state() + .await + .wallet() + .get_bip44_account(0) + .expect("account 0") + .account_xpub + .to_string(); + assert_eq!( + marker.as_deref(), + Some(format!("{expected_xpub}|stamp-1").as_str()), + "marker binds the account-0 xpub to the Keychain-item stamp" + ); + } + + /// Later launch (marker matches the wallet's current xpub AND the current + /// Keychain-item stamp): the signer is not consulted at all — proven by + /// passing a signer for the WRONG seed, which would fail the full check, + /// and still getting `MarkerMatched`. Nothing new to persist. + #[tokio::test] + async fn verify_with_matching_marker_skips_signer() { + let manager = make_manager(); + let network = Network::Testnet; + let seed = seed_for(TEST_MNEMONIC); + + let wallet = manager + .create_wallet_from_seed_bytes( + network, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet creation"); + let xpub = wallet + .state() + .await + .wallet() + .get_bip44_account(0) + .expect("account 0") + .account_xpub + .to_string(); + let marker = format!("{xpub}|stamp-1"); + + let wrong_seed = + seed_for("legal winner thank year wave sausage worth useful legal winner thank yellow"); + let wrong_crypto = SeedCryptoProvider::from_seed(wrong_seed, network); + + let (outcome, returned) = wallet + .verify_seed_binds_with_marker(&wrong_crypto, Some(&marker), Some("stamp-1")) + .await + .expect("a matching marker must short-circuit before the signer runs"); + assert_eq!(outcome, SeedBindingVerification::MarkerMatched); + assert_eq!(returned, None, "a matched marker has nothing to persist"); + } + + /// The mnemonic Keychain item was rewritten since the marker was + /// persisted (its stamp changed): the marker must NOT match even though + /// the xpub part is current, and the full check must re-run against the + /// item's actual content — a replaced/mis-mapped mnemonic is rejected + /// with `SeedMismatch`; the original mnemonic re-verifies and hands back + /// a marker carrying the new stamp. + #[tokio::test] + async fn verify_with_changed_keychain_stamp_reruns_full_check() { + let manager = make_manager(); + let network = Network::Testnet; + let seed = seed_for(TEST_MNEMONIC); + + let wallet = manager + .create_wallet_from_seed_bytes( + network, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet creation"); + let xpub = wallet + .state() + .await + .wallet() + .get_bip44_account(0) + .expect("account 0") + .account_xpub + .to_string(); + // Marker verified while the item carried stamp-1; the item has since + // been rewritten and now carries stamp-2. + let marker = format!("{xpub}|stamp-1"); + + // The rewrite put a DIFFERENT mnemonic in the slot → must be caught. + let wrong_seed = + seed_for("legal winner thank year wave sausage worth useful legal winner thank yellow"); + let wrong_crypto = SeedCryptoProvider::from_seed(wrong_seed, network); + let err = wallet + .verify_seed_binds_with_marker(&wrong_crypto, Some(&marker), Some("stamp-2")) + .await + .expect_err("a stamp change must force the full check, catching a replaced mnemonic"); + assert!( + matches!(err, PlatformWalletError::SeedMismatch { .. }), + "expected SeedMismatch, got: {err:?}" + ); + + // The rewrite re-stored the SAME mnemonic → re-verifies, and the new + // marker carries the new stamp. + let crypto = SeedCryptoProvider::from_seed(seed, network); + let (outcome, returned) = wallet + .verify_seed_binds_with_marker(&crypto, Some(&marker), Some("stamp-2")) + .await + .expect("the wallet's own seed must re-verify after a stamp change"); + assert_eq!(outcome, SeedBindingVerification::Verified); + assert_eq!(returned, Some(format!("{xpub}|stamp-2"))); + } + + /// No Keychain stamp available: there is no safe candidate to match, so + /// the full check always runs and nothing is handed back to cache — the + /// caller keeps verifying every launch (fail-safe: cache disabled, never + /// bypassed). + #[tokio::test] + async fn verify_without_stamp_never_matches_and_returns_no_marker() { + let manager = make_manager(); + let network = Network::Testnet; + let seed = seed_for(TEST_MNEMONIC); + + let wallet = manager + .create_wallet_from_seed_bytes( + network, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet creation"); + let xpub = wallet + .state() + .await + .wallet() + .get_bip44_account(0) + .expect("account 0") + .account_xpub + .to_string(); + + let crypto = SeedCryptoProvider::from_seed(seed, network); + let (outcome, returned) = wallet + .verify_seed_binds_with_marker(&crypto, Some(&format!("{xpub}|stamp-1")), None) + .await + .expect("the wallet's own seed must bind"); + assert_eq!( + outcome, + SeedBindingVerification::Verified, + "without a stamp the marker must not match" + ); + assert_eq!(returned, None, "without a stamp there is nothing to cache"); + } + + /// A stale marker (wallet re-imported / xpub changed since it was written) + /// falls through to the full signer check: the right signer re-verifies + /// and hands back the fresh marker; the wrong signer is still rejected. + #[tokio::test] + async fn verify_with_stale_marker_falls_through_to_full_check() { + let manager = make_manager(); + let network = Network::Testnet; + let seed = seed_for(TEST_MNEMONIC); + + let wallet = manager + .create_wallet_from_seed_bytes( + network, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet creation"); + let current_xpub = wallet + .state() + .await + .wallet() + .get_bip44_account(0) + .expect("account 0") + .account_xpub + .to_string(); + + let crypto = SeedCryptoProvider::from_seed(seed, network); + let (outcome, marker) = wallet + .verify_seed_binds_with_marker( + &crypto, + Some("stale-marker-from-a-previous-import"), + Some("stamp-1"), + ) + .await + .expect("the wallet's own seed must bind after a stale marker"); + assert_eq!(outcome, SeedBindingVerification::Verified); + assert_eq!(marker, Some(format!("{current_xpub}|stamp-1"))); + + let wrong_seed = + seed_for("legal winner thank year wave sausage worth useful legal winner thank yellow"); + let wrong_crypto = SeedCryptoProvider::from_seed(wrong_seed, network); + let err = wallet + .verify_seed_binds_with_marker( + &wrong_crypto, + Some("stale-marker-from-a-previous-import"), + Some("stamp-1"), + ) + .await + .expect_err("a stale marker must not let a wrong signer through"); + assert!( + matches!(err, PlatformWalletError::SeedMismatch { .. }), + "expected SeedMismatch, got: {err:?}" + ); + } + /// A wallet with no BIP44 account 0 (created without the default account /// set) is refused with `InvalidIdentityData` — the gate has no persisted /// xpub to bind against, so it must fail closed rather than pass silently. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift index 7d039b224e4..1315f49f93f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift @@ -153,6 +153,43 @@ public class WalletStorage { return status == errSecSuccess } + /// Attribute-only identity stamp of the wallet's mnemonic Keychain + /// item, or `nil` when no item exists (or attributes are unreadable). + /// + /// Built from the item's creation + modification dates, which change + /// whenever the item is written: `storeMnemonic` is delete-then-add + /// (fresh dates every call) and any in-place `SecItemUpdate` bumps + /// the modification date. The seed-binding verification cache binds + /// its marker to this stamp so a rewritten or re-created mnemonic + /// item invalidates the cached verification and forces a full + /// re-verify — without this, a marker verified against an older + /// mnemonic would keep passing after the item changed. + /// + /// Like `hasMnemonic`, this queries attributes only — the secret is + /// never materialized. + public func mnemonicKeychainStamp(for walletId: Data) -> String? { + 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? + 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. public func deleteMnemonic(for walletId: Data) throws { let account = perWalletMnemonicAccount(for: walletId) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift index a32c1159f29..2e292c07366 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift @@ -144,11 +144,13 @@ public final class MnemonicResolver: @unchecked Sendable { ) -> MnemonicResolverResult { // Secret-free audit line: every mnemonic pull through a resolver // handle is observable, so "the launch path never touches the - // mnemonic" is checkable from the unified log alone (shielded - // binds rehydrate persisted viewing keys and must NOT appear - // here; spends and first binds legitimately do). NSLog rather - // than SDKLogger.log so the audit trail is emitted regardless - // of the user's logging preset. + // mnemonic" is checkable from the unified log alone. Shielded + // binds rehydrate persisted viewing keys and warm unlocks + // short-circuit on the persisted seed-binding marker, so neither + // may appear here; spends, signing, first binds, and first-launch + // seed-bind verifies legitimately do. NSLog rather than + // SDKLogger.log so the audit trail is emitted regardless of the + // user's logging preset. NSLog( "MnemonicResolver.resolve fired for wallet %@…", walletId.prefix(4).map { String(format: "%02x", $0) }.joined() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index e900ce0e332..6d6e80644a4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -93,6 +93,18 @@ public final class PersistentWallet { /// drives the "📥 Imported" badge; defaulted to `false` for /// rows that predate the column. public var isImported: Bool = false + /// Verified seed-binding marker: the BIP44 account-0 xpub that the + /// Keychain-resolved seed was proven to derive, bound to the mnemonic + /// Keychain item's identity stamp, written after one successful + /// `platform_wallet_verify_seed_binds_to_wallet_cached` run. On later + /// launches the unlock path hands this back to Rust (with the item's + /// current stamp), which skips the mnemonic-resolving derivation when + /// it still matches — and re-verifies when the xpub OR the Keychain + /// item changed. Opaque to Swift — Rust decides match-vs-verify; this + /// column only stores and returns it. `nil` (rows predating the + /// column, or never verified) means the full check runs at the next + /// unlock. + public var seedBindingVerifiedMarker: String? /// Record timestamps. public var createdAt: Date public var lastUpdated: Date diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index eee780a4513..f2255597c2a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -487,9 +487,13 @@ public class PlatformWalletManager: ObservableObject { // one wallet can't fail the whole restore. do { let unlocked = try unlockWalletFromKeychain(managedWallet) - print( - "🔓 wallet unlock \(walletId.toHexString().prefix(8)): " - + (unlocked ? "seed verified — drain scheduled" : "no mnemonic — stays watch-only") + // NSLog (not print) so the unlock outcome is observable + // off-Xcode — it pairs with the resolver audit line in + // MnemonicResolver.resolve for "what touched the seed". + NSLog( + "🔓 wallet unlock %@: %@", + String(walletId.toHexString().prefix(8)), + unlocked ? "seed verified" : "no mnemonic — stays watch-only" ) } catch let error as PlatformWalletError { // Distinguish a wrong-seed binding (Rust `SeedMismatch` → @@ -550,14 +554,22 @@ public class PlatformWalletManager: ObservableObject { /// two things, both through a resolver (the seed never becomes resident): /// /// 1. **Verify** the resolved seed binds to this wallet — - /// `platform_wallet_verify_seed_binds_to_wallet` derives the BIP44 - /// account-0 xpub through the resolver and compares it to the + /// `platform_wallet_verify_seed_binds_to_wallet_cached` derives the + /// BIP44 account-0 xpub through the resolver and compares it to the /// persisted one. A mis-mapped Keychain slot derives a different xpub /// and the call throws, so a wrong seed never signs for this wallet. + /// The outcome is launch-invariant while the mnemonic Keychain item + /// is untouched, so the first success persists a marker (the xpub + /// bound to the item's identity stamp) on the wallet row and later + /// launches skip the resolver. Rust re-runs the full check when the + /// marker no longer matches — wallet re-import, or any rewrite of + /// the mnemonic Keychain item (which changes the stamp). /// 2. **Drain** (in the background) any contact-crypto deferred while /// the wallet was seedless — `platform_wallet_drain_pending_contact_crypto`. /// The drain re-fetches + decrypts over the network, so it runs in a - /// detached task off the caller's thread. + /// detached task off the caller's thread. Scheduled only when the + /// signerless `platform_wallet_pending_contact_crypto_count` probe + /// reports queued work. /// /// Per the Swift-SDK FFI boundary rules, the mnemonic → seed conversion /// happens entirely inside the resolver vtable in Rust; Swift only @@ -565,9 +577,10 @@ public class PlatformWalletManager: ObservableObject { /// the plaintext across. /// /// - Parameter wallet: the restored `ManagedPlatformWallet`. - /// - Returns: `true` if the wallet's seed verified (drain scheduled); - /// `false` if no mnemonic is stored for this wallet (a genuine - /// watch-only wallet), without throwing. + /// - Returns: `true` if the wallet's seed verified (drain scheduled when + /// the pending-ops probe reports queued work); `false` if no mnemonic + /// is stored for this wallet (a genuine watch-only wallet), without + /// throwing. /// - Throws: `PlatformWalletError` if the verify FFI fails (e.g. the /// resolved seed does not bind — a mis-mapped Keychain slot). @discardableResult @@ -583,7 +596,8 @@ public class PlatformWalletManager: ObservableObject { // A genuine watch-only wallet (imported by xpub, never holding a // seed) has no Keychain mnemonic — stays watch-only. Existence-only // check; the plaintext is never materialized in Swift. - guard WalletStorage().hasMnemonic(for: walletId) else { + let walletStorage = WalletStorage() + guard walletStorage.hasMnemonic(for: walletId) else { return false } @@ -592,9 +606,19 @@ public class PlatformWalletManager: ObservableObject { // inside the resolver vtable Rust-side; no resident seed. let coreSigner = MnemonicResolver() - // Wrong-seed / wrong-wallet gate. `withExtendedLifetime` keeps the - // resolver alive across the synchronous FFI call (its vtable callback - // fires during it). Throws if the resolved seed derives a different + // Wrong-seed / wrong-wallet gate, marker-cached: the check is a pure + // function of (mnemonic, network) against the wallet's persisted + // account-0 xpub, so after one successful verify Rust hands back a + // marker — the verified xpub bound to the mnemonic Keychain item's + // identity stamp — that Swift persists on the wallet row. Later + // launches pass both back and Rust skips the resolver entirely while + // the marker still matches; any rewrite of the Keychain item changes + // the stamp and forces the full check again, so a replaced mnemonic + // can never coast on an old verification. Swift only loads/stores + // the marker and reads the stamp; match-vs-verify is decided in + // Rust. `withExtendedLifetime` keeps the resolver alive across the + // synchronous FFI call (its vtable callback fires during it, when a + // full verify runs). Throws if the resolved seed derives a different // BIP44 account-0 xpub than the wallet's persisted one. // // Publish the per-wallet `seedMismatch` from the verify result itself, @@ -604,11 +628,59 @@ public class PlatformWalletManager: ObservableObject { // mistaken for a seed mismatch. Rethrow so the existing caller handling // (loadFromPersistor's log-and-continue) is unchanged. do { + let storedMarker = persistenceHandler?.seedBindingMarker(walletId: walletId) + // Attribute-only stamp of the mnemonic Keychain item (secret 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. + 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. + var newMarkerPtr: UnsafeMutablePointer? = nil + defer { + if let ptr = newMarkerPtr { platform_wallet_string_free(ptr) } + } try withExtendedLifetime(coreSigner) { - try platform_wallet_verify_seed_binds_to_wallet( - walletHandle, - coreSigner.handle - ).check() + // Nested withCString over the two optional inputs; nil maps to + // a null pointer (the FFI treats both as "absent"). + func callVerify( + _ markerPtr: UnsafePointer?, + _ stampPtr: UnsafePointer? + ) -> PlatformWalletFFIResult { + platform_wallet_verify_seed_binds_to_wallet_cached( + walletHandle, + coreSigner.handle, + markerPtr, + stampPtr, + &newMarkerPtr + ) + } + let result: PlatformWalletFFIResult + switch (storedMarker, keychainStamp) { + case let (marker?, stamp?): + result = marker.withCString { m in + stamp.withCString { s in callVerify(m, s) } + } + case let (marker?, nil): + result = marker.withCString { m in callVerify(m, nil) } + case let (nil, stamp?): + result = stamp.withCString { s in callVerify(nil, s) } + case (nil, nil): + result = callVerify(nil, nil) + } + try result.check() + } + if let ptr = newMarkerPtr { + persistenceHandler?.setSeedBindingMarker( + walletId: walletId, + marker: String(cString: ptr) + ) + NSLog( + "🔐 seed binding verified via resolver for %@ — marker " + + "persisted; later launches skip the derivation", + String(walletId.toHexString().prefix(8)) + ) } setDashPaySeedMismatch(walletId, false) } catch let error as PlatformWalletError { @@ -626,6 +698,31 @@ public class PlatformWalletManager: ObservableObject { // scalar until this heals, so it never needs to block unlock. persistenceHandler?.scheduleBackfillIdentityKeyBreadcrumbs(walletId: walletId) + // Only schedule the background drain when the signerless probe + // reports queued work. This is the TOTAL drainable count — every op + // kind, including the ContactInfoDecrypt refreshes that the + // user-facing banner count excludes — because the unlock drain is + // the launch-time application point for cross-device contact + // metadata; gating on the filtered count would strand + // ContactInfoDecrypt-only queues until some other signer-present + // action ran a drain. On a probe failure fall through and schedule + // the drain (old behavior): it is always safe to run. + var pendingOps: UInt32 = 0 + let countResult = platform_wallet_drainable_contact_crypto_count( + walletHandle, &pendingOps + ) + if PlatformWalletResultCode(ffi: countResult.code) == .success && pendingOps == 0 { + NSLog( + "🧵 contact-crypto drain skipped for %@ — no pending ops", + String(walletId.toHexString().prefix(8)) + ) + return true + } + NSLog( + "🧵 contact-crypto drain scheduling for %@ — %u pending op(s)", + String(walletId.toHexString().prefix(8)), pendingOps + ) + // Don't stack a second drain on an in-flight one: a banner Unlock tap // (or a second unlock) while a drain runs would duplicate the network // re-fetch + ECDH work and race the channel-broken writes. The banner diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 6356da9db96..db0d596703e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3676,6 +3676,30 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Load the persisted seed-binding marker for `walletId`, or `nil` + /// if none was ever written (first launch, pre-column row). The + /// marker is opaque to Swift — it round-trips into + /// `platform_wallet_verify_seed_binds_to_wallet_cached`, where Rust + /// decides whether it still proves the binding. + public func seedBindingMarker(walletId: Data) -> String? { + onQueue { + findWalletRecord(walletId: walletId)?.seedBindingVerifiedMarker + } + } + + /// Persist the seed-binding marker the cached verify FFI handed + /// back (it returns one only when a full verification ran and + /// bound). Silently skips if the row is missing, mirroring + /// `setWalletName`. + public func setSeedBindingMarker(walletId: Data, marker: String) { + onQueue { + guard let wallet = findWalletRecord(walletId: walletId) else { return } + wallet.seedBindingVerifiedMarker = marker + wallet.lastUpdated = Date() + try? backgroundContext.save() + } + } + /// Count `PersistentWallet` rows for `walletId` across ALL /// networks (deliberately ignores `self.network`). The mnemonic / /// metadata in the Keychain are shared by every network's row, so