From 92c977e04f2576fb1d0e82b2d44864e951d0b21d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 20:31:08 +0700 Subject: [PATCH 1/2] feat(platform-wallet): skip startup mnemonic touches via seed-binding marker + gated contact-crypto drain The unlock path ran platform_wallet_verify_seed_binds_to_wallet for every restored wallet at every launch, resolving the Keychain mnemonic and re-deriving the BIP44 account-0 xpub even though the outcome is a pure function of (mnemonic, network) vs the persisted xpub. Verify once, persist the verified xpub as a marker on the wallet row, and let Rust skip the resolver on later launches while the marker still matches (re-import invalidates it and re-runs the full check). Also gate the per-wallet background contact-crypto drain on the signerless pending-ops probe so an empty queue schedules nothing at unlock. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet-ffi/src/dashpay.rs | 151 ++++++++++++++++ packages/rs-platform-wallet/src/lib.rs | 3 +- .../src/wallet/identity/network/mod.rs | 1 + .../wallet/identity/network/seed_binding.rs | 170 +++++++++++++++++- .../FFI/MnemonicResolverAndPersister.swift | 7 + .../Persistence/Models/PersistentWallet.swift | 10 ++ .../PlatformWalletManager.swift | 110 ++++++++++-- .../PlatformWalletPersistenceHandler.swift | 24 +++ 8 files changed, 457 insertions(+), 19 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 29dbb70442d..d4a6a0e6c5b 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -981,6 +981,100 @@ 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 until the xpub changes (wallet re-import). The host persists +/// the marker this call hands back and passes it in on the next launch; +/// when it still matches, Rust skips the resolver entirely — no Keychain +/// mnemonic fetch, no derivation. +/// +/// 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 and bound — that is the host's signal to persist +/// the new marker. It stays null when the supplied marker matched (nothing to +/// persist). 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. +/// - `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, + 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!(CStr::from_ptr(verified_marker).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()) + .await + }) + }); + let result = unwrap_option_or_return!(option); + match result { + Ok((platform_wallet::SeedBindingVerification::MarkerMatched, _)) => { + PlatformWalletFFIResult::ok() + } + Ok((platform_wallet::SeedBindingVerification::Verified, 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(), + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1008,6 +1102,63 @@ 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(), + &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_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(), + &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/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..6f7674f046c 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,20 @@ 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 — the binding was proven on an earlier launch + /// and the xpub hasn't 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 so later launches can skip the derivation. + Verified, +} + impl PlatformWallet { /// Verify the signer behind `crypto` resolves the seed that owns this wallet. /// @@ -31,6 +45,27 @@ impl PlatformWallet { &self, crypto: &C, ) -> Result<(), PlatformWalletError> { + self.verify_seed_binds_with_marker(crypto, 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. After one successful verify the + /// host persists the returned marker (the verified account-0 xpub, + /// serialized) and passes it back on later launches; when it still equals + /// the wallet's current account-0 xpub the signer is skipped entirely — no + /// mnemonic resolution, no derivation. A differing or absent marker (first + /// launch, wallet re-import) falls through to the full signer check. + /// + /// Returns the outcome plus the marker to persist. 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>, + ) -> Result<(SeedBindingVerification, String), 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 +84,14 @@ impl PlatformWallet { (path, account.account_xpub) }; + let expected_marker = expected.to_string(); + if marker == Some(expected_marker.as_str()) { + return Ok((SeedBindingVerification::MarkerMatched, expected_marker)); + } + let derived = crypto.receiving_xpub(&path).await?; if derived == expected { - Ok(()) + Ok((SeedBindingVerification::Verified, expected_marker)) } else { Err(PlatformWalletError::SeedMismatch { wallet_id: hex::encode(self.wallet_id()), @@ -62,6 +102,7 @@ impl PlatformWallet { #[cfg(test)] mod tests { + use super::SeedBindingVerification; use std::sync::Arc; use key_wallet::mnemonic::{Language, Mnemonic}; @@ -180,6 +221,133 @@ mod tests { ); } + /// First launch (no marker): the full signer check runs, binds, and + /// returns `Verified` plus the marker for the host to persist — which is + /// the wallet's account-0 xpub serialization. + #[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) + .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, expected_xpub, "marker is the account-0 xpub"); + } + + /// Later launch (marker matches the wallet's current xpub): 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`. + #[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 marker = wallet + .state() + .await + .wallet() + .get_bip44_account(0) + .expect("account 0") + .account_xpub + .to_string(); + + 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)) + .await + .expect("a matching marker must short-circuit before the signer runs"); + assert_eq!(outcome, SeedBindingVerification::MarkerMatched); + assert_eq!(returned, marker, "marker round-trips unchanged"); + } + + /// 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")) + .await + .expect("the wallet's own seed must bind after a stale marker"); + assert_eq!(outcome, SeedBindingVerification::Verified); + assert_eq!(marker, current_xpub); + + 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"), + ) + .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/FFI/MnemonicResolverAndPersister.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift index 44dade9c1dc..b048bce25e9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift @@ -142,6 +142,13 @@ public final class MnemonicResolver: @unchecked Sendable { outCapacity: UInt, outLen: UnsafeMutablePointer ) -> MnemonicResolverResult { + // Every mnemonic materialization funnels through here, so this one + // line is the audit trail for "what touched the seed": expect it + // during signing and on a first-launch seed-bind verify, and expect + // its absence on later launches' unlock path (the persisted binding + // marker short-circuits before the resolver runs). NSLog rather than + // print so the line is observable off-Xcode (system log / console-pty). + NSLog("🔑 MnemonicResolver.resolve for wallet %@", String(walletId.toHexString().prefix(8))) let mnemonicUTF8Bytes: Data do { mnemonicUTF8Bytes = try storage.retrieveMnemonicUTF8Bytes(for: walletId) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index e900ce0e332..31296def8f6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -93,6 +93,16 @@ 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, written after one + /// successful `platform_wallet_verify_seed_binds_to_wallet_cached` + /// run. On later launches the unlock path hands this back to Rust, + /// which skips the mnemonic-resolving derivation when it still + /// matches the wallet's current xpub. 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..adce1de3eb8 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,20 @@ 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, so the first success persists a + /// marker on the wallet row and later launches skip the resolver + /// (Rust re-runs the full check only when the marker no longer + /// matches the wallet's xpub, e.g. after a re-import). /// 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 +575,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 @@ -592,10 +603,17 @@ 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 - // BIP44 account-0 xpub than the wallet's persisted one. + // 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) that Swift persists on the wallet row. + // Later launches pass it back and Rust skips the resolver entirely + // while it still matches — zero mnemonic touches on the warm unlock + // path. Swift only loads/stores the marker; 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, // scoped to JUST this call: the verify FFI maps Rust `SeedMismatch` → @@ -604,11 +622,44 @@ 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) + // 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() + let result: PlatformWalletFFIResult + if let storedMarker { + result = storedMarker.withCString { markerPtr in + platform_wallet_verify_seed_binds_to_wallet_cached( + walletHandle, + coreSigner.handle, + markerPtr, + &newMarkerPtr + ) + } + } else { + result = platform_wallet_verify_seed_binds_to_wallet_cached( + walletHandle, + coreSigner.handle, + nil, + &newMarkerPtr + ) + } + 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 +677,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 count probe + // reports actionable work (account-build + auto-accept ops). Rust + // deliberately excludes ContactInfoDecrypt from the count — it + // re-enqueues on every sync sweep, so it is structurally always + // present and would defeat the gate; skipping it here defers the + // contactInfo refresh (a resolver → Keychain touch) to the next + // signer-present DashPay action's own drain, which is where it + // drains anyway. 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_pending_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 54917b5f99e..e5f4cd11bd5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3544,6 +3544,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 From fbe538cdc8696cc917245df04ca87971936947ad Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 21:53:47 +0700 Subject: [PATCH 2/2] fix(platform-wallet): bind seed-binding marker to Keychain-item stamp + gate drain on total drainable count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (PR #4125): - The cached verification marker is now bound to an identity stamp of the mnemonic Keychain item (creation+modification dates, attribute-only read) in addition to the account-0 xpub. Any rewrite or re-creation of the item changes the stamp and forces the full resolver check, so a replaced or mis-mapped mnemonic can never coast on an old verification. No stamp → cache disabled (full check every launch), never bypassed. - The unlock drain gate now uses a new total drainable count that includes ContactInfoDecrypt, so cross-device contact metadata still applies at launch; the filtered count remains for the user-facing banner. - Wrap CStr::from_ptr in explicit unsafe blocks in the cached verify FFI. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet-ffi/src/dashpay.rs | 95 +++++++-- .../identity/network/contact_requests.rs | 20 ++ .../wallet/identity/network/seed_binding.rs | 183 +++++++++++++++--- .../Core/Wallet/WalletStorage.swift | 37 ++++ .../Persistence/Models/PersistentWallet.swift | 18 +- .../PlatformWalletManager.swift | 93 +++++---- 6 files changed, 353 insertions(+), 93 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index d4a6a0e6c5b..67c4b71bd5b 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -983,18 +983,24 @@ 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 until the xpub changes (wallet re-import). The host persists -/// the marker this call hands back and passes it in on the next launch; -/// when it still matches, Rust skips the resolver entirely — no Keychain -/// mnemonic fetch, no derivation. +/// 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 and bound — that is the host's signal to persist -/// the new marker. It stays null when the supplied marker matched (nothing to -/// persist). A mismatched derivation fails with `ErrorInvalidParameter` -/// exactly like the non-cached variant, leaving `*out_marker` null. +/// 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 @@ -1002,12 +1008,16 @@ pub unsafe extern "C" fn platform_wallet_verify_seed_binds_to_wallet( /// - `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); @@ -1020,7 +1030,18 @@ pub unsafe extern "C" fn platform_wallet_verify_seed_binds_to_wallet_cached( let marker: Option = if verified_marker.is_null() { None } else { - Some(unwrap_result_or_return!(CStr::from_ptr(verified_marker).to_str()).to_string()) + 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; @@ -1039,7 +1060,7 @@ pub unsafe extern "C" fn platform_wallet_verify_seed_binds_to_wallet_cached( let wallet = wallet.clone(); block_on_worker(async move { wallet - .verify_seed_binds_with_marker(&provider, marker.as_deref()) + .verify_seed_binds_with_marker(&provider, marker.as_deref(), stamp.as_deref()) .await }) }); @@ -1049,16 +1070,18 @@ pub unsafe extern "C" fn platform_wallet_verify_seed_binds_to_wallet_cached( PlatformWalletFFIResult::ok() } Ok((platform_wallet::SeedBindingVerification::Verified, 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(), - ) + 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(); } - }; - unsafe { - *out_marker = c_marker.into_raw(); } PlatformWalletFFIResult::ok() } @@ -1075,6 +1098,35 @@ pub unsafe extern "C" fn platform_wallet_verify_seed_binds_to_wallet_cached( } } +/// 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::*; @@ -1113,6 +1165,7 @@ mod tests { 1, std::ptr::null_mut(), std::ptr::null(), + std::ptr::null(), &mut out_marker, ) }; @@ -1129,6 +1182,7 @@ mod tests { 1, dummy_signer, std::ptr::null(), + std::ptr::null(), std::ptr::null_mut(), ) }; @@ -1149,6 +1203,7 @@ mod tests { 0xDEAD_BEEF, dummy_signer, std::ptr::null(), + std::ptr::null(), &mut out_marker, ) }; 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/seed_binding.rs b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs index 6f7674f046c..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 @@ -19,12 +19,14 @@ use crate::wallet::platform_wallet::PlatformWallet; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SeedBindingVerification { /// The host's persisted verified-binding marker equals the wallet's - /// current account-0 xpub — the binding was proven on an earlier launch - /// and the xpub hasn't changed since, so the signer was not consulted. + /// 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 so later launches can skip the derivation. + /// marker (when one is handed back) so later launches can skip the + /// derivation. Verified, } @@ -45,27 +47,36 @@ impl PlatformWallet { &self, crypto: &C, ) -> Result<(), PlatformWalletError> { - self.verify_seed_binds_with_marker(crypto, None) + 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. After one successful verify the - /// host persists the returned marker (the verified account-0 xpub, - /// serialized) and passes it back on later launches; when it still equals - /// the wallet's current account-0 xpub the signer is skipped entirely — no - /// mnemonic resolution, no derivation. A differing or absent marker (first - /// launch, wallet re-import) falls through to the full signer check. + /// 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. Errors exactly like - /// [`Self::verify_seed_binds`] when the full check runs and fails. + /// 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>, - ) -> Result<(SeedBindingVerification, String), PlatformWalletError> { + 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`. @@ -84,14 +95,19 @@ impl PlatformWallet { (path, account.account_xpub) }; - let expected_marker = expected.to_string(); - if marker == Some(expected_marker.as_str()) { - return Ok((SeedBindingVerification::MarkerMatched, expected_marker)); + // 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((SeedBindingVerification::Verified, expected_marker)) + Ok((SeedBindingVerification::Verified, candidate)) } else { Err(PlatformWalletError::SeedMismatch { wallet_id: hex::encode(self.wallet_id()), @@ -222,8 +238,8 @@ mod tests { } /// First launch (no marker): the full signer check runs, binds, and - /// returns `Verified` plus the marker for the host to persist — which is - /// the wallet's account-0 xpub serialization. + /// 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(); @@ -242,7 +258,7 @@ mod tests { let crypto = SeedCryptoProvider::from_seed(seed, network); let (outcome, marker) = wallet - .verify_seed_binds_with_marker(&crypto, None) + .verify_seed_binds_with_marker(&crypto, None, Some("stamp-1")) .await .expect("the wallet's own seed must bind"); assert_eq!(outcome, SeedBindingVerification::Verified); @@ -255,12 +271,17 @@ mod tests { .expect("account 0") .account_xpub .to_string(); - assert_eq!(marker, expected_xpub, "marker is the account-0 xpub"); + 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): 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`. + /// 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(); @@ -276,7 +297,7 @@ mod tests { ) .await .expect("wallet creation"); - let marker = wallet + let xpub = wallet .state() .await .wallet() @@ -284,17 +305,116 @@ mod tests { .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)) + .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, marker, "marker round-trips unchanged"); + 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) @@ -326,11 +446,15 @@ mod tests { let crypto = SeedCryptoProvider::from_seed(seed, network); let (outcome, marker) = wallet - .verify_seed_binds_with_marker(&crypto, Some("stale-marker-from-a-previous-import")) + .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, current_xpub); + 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"); @@ -339,6 +463,7 @@ mod tests { .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"); 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/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 31296def8f6..6d6e80644a4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -94,14 +94,16 @@ public final class PersistentWallet { /// 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, written after one - /// successful `platform_wallet_verify_seed_binds_to_wallet_cached` - /// run. On later launches the unlock path hands this back to Rust, - /// which skips the mnemonic-resolving derivation when it still - /// matches the wallet's current xpub. 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. + /// 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index adce1de3eb8..f2255597c2a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -558,10 +558,12 @@ public class PlatformWalletManager: ObservableObject { /// 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, so the first success persists a - /// marker on the wallet row and later launches skip the resolver - /// (Rust re-runs the full check only when the marker no longer - /// matches the wallet's xpub, e.g. after a re-import). + /// 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 @@ -594,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 } @@ -606,14 +609,17 @@ public class PlatformWalletManager: ObservableObject { // 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) that Swift persists on the wallet row. - // Later launches pass it back and Rust skips the resolver entirely - // while it still matches — zero mnemonic touches on the warm unlock - // path. Swift only loads/stores the marker; 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. + // 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, // scoped to JUST this call: the verify FFI maps Rust `SeedMismatch` → @@ -623,6 +629,12 @@ public class PlatformWalletManager: ObservableObject { // (loadFromPersistor's log-and-continue) is unchanged. do { let storedMarker = persistenceHandler?.seedBindingMarker(walletId: walletId) + // Attribute-only stamp of the mnemonic Keychain item (secret never + // 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 @@ -630,24 +642,33 @@ public class PlatformWalletManager: ObservableObject { if let ptr = newMarkerPtr { platform_wallet_string_free(ptr) } } try withExtendedLifetime(coreSigner) { - let result: PlatformWalletFFIResult - if let storedMarker { - result = storedMarker.withCString { markerPtr in - platform_wallet_verify_seed_binds_to_wallet_cached( - walletHandle, - coreSigner.handle, - markerPtr, - &newMarkerPtr - ) - } - } else { - result = platform_wallet_verify_seed_binds_to_wallet_cached( + // 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, - nil, + 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 { @@ -677,17 +698,17 @@ 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 count probe - // reports actionable work (account-build + auto-accept ops). Rust - // deliberately excludes ContactInfoDecrypt from the count — it - // re-enqueues on every sync sweep, so it is structurally always - // present and would defeat the gate; skipping it here defers the - // contactInfo refresh (a resolver → Keychain touch) to the next - // signer-present DashPay action's own drain, which is where it - // drains anyway. On a probe failure fall through and schedule the - // drain (old behavior): it is always safe to run. + // 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_pending_contact_crypto_count( + let countResult = platform_wallet_drainable_contact_crypto_count( walletHandle, &pendingOps ) if PlatformWalletResultCode(ffi: countResult.code) == .success && pendingOps == 0 {