From 3ecb5f8ab2ada0d9a91797b1220f049886c7f4ac Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:03:48 +0200 Subject: [PATCH 01/24] feat(wallet-backend): land restart-in-place machinery (gated; barrier stays live) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the DET-side foundation for Option A (keep-alive + SPV/coordinator restart-in-place), staged so the branch is Q3-safe at every commit. The live reconnect path is UNCHANGED (drop+rebuild + await_persister_released barrier), which is immune to the upstream platform_address_sync restart race because it builds fresh coordinator instances each reconnect. What this adds (dormant until the flip): - coordinator_gate.rs: make CoordinatorGate re-armable. action is now Mutex> (was OnceLock) and a reset() clears action + fired + masternodes_ready, so a reused gate re-fires the coordinators on a reconnect. masternodes_ready is cleared because a restart re-syncs the masternode list from scratch; coordinators must re-wait for Synced or they fire proofs before quorums exist and self-ban. - wallet_backend/mod.rs: StartLatch::reset() re-arms the one-shot start latch; WalletBackend::stop_in_place() stops SPV (pwm.spv().stop()) and quiesces the 3 coordinators via their Arc accessors WITHOUT pwm.shutdown() (which would cancel+join the non-restartable wallet-event adapter), then re-arms the start latch + gate so start() can restart on the SAME backend. ensure_wallet_backend already fast-paths on a populated slot, so a reconnect reuses the instance. - Tests: reset_re_arms_gate_for_restart_in_place, start_latch_reset_allows_restart (run normally); reconnect_restart_in_place_reuses_backend (#[ignore], asserts same-backend reuse + restart, no AlreadyOpen). HARD DEPENDENCY (why this is gated, not wired): restart-in-place re-start()s the SAME platform_address_sync instance, which lacks the background_generation guard its siblings have in the pinned platform rev 925b109. Against the guard-less rev a rapid reconnect can leak an uncancellable / duplicate platform-address loop (Q3). So stop_spv is NOT switched to restart-in-place and the barrier is NOT deleted here. A TODO at the stop_spv flip site documents the activation: land the upstream guard in branch fix/wallet-core-derived-rehydration (dashpay/platform #3828 tracks it), cargo update the platform crates to a rev that contains it, then flip stop_spv to stop_in_place and delete await_persister_released. Out of scope (noted): DET-subtask cancellation (Marvin V-1) — under keep-alive the retained persister Arc makes a lingering subtask harmless for AlreadyOpen; still worth doing later for clean app-exit. Co-Authored-By: Claude Opus 4.8 --- src/context/wallet_lifecycle.rs | 92 +++++++++++++++++++++++ src/wallet_backend/coordinator_gate.rs | 100 ++++++++++++++++++++++--- src/wallet_backend/mod.rs | 75 +++++++++++++++++++ 3 files changed, 255 insertions(+), 12 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 0082f3529..03a4d53fb 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -377,6 +377,28 @@ impl AppContext { self.connection_status.set_spv_status(SpvStatus::Stopping); self.connection_status.refresh_state(); + // CURRENT (Q3-safe) reconnect model: drop + rebuild. `take_wallet_backend` + // unwires the backend, `shutdown()` stops SPV + coordinators, the + // backend is dropped, and `await_persister_released` waits out the + // detached coordinator threads before the next reconnect reopens the + // persister. Each reconnect builds FRESH coordinator instances, so the + // upstream `platform_address_sync` restart race (Q3) cannot occur. + // + // TODO: enable restart-in-place once dashpay/platform#3828 lands the + // `platform_address_sync` `background_generation` guard in the pinned + // `fix/wallet-core-derived-rehydration` branch and we `cargo update` + // the platform crates to a rev that contains it. The flip is: + // - replace this `take_wallet_backend()` + `shutdown()` + drop + + // `await_persister_released` block with + // `if let Ok(backend) = self.wallet_backend() { backend.stop_in_place().await; }` + // (keeps the backend + persister wired; reconnect reuses the SAME + // instance via `ensure_wallet_backend`'s populated-slot fast path), + // - delete `await_persister_released` and its offline test, + // - keep the `set_masternodes_ready(false)` + indicator flips below. + // The machinery (`WalletBackend::stop_in_place`, `CoordinatorGate::reset`, + // `StartLatch::reset`) is already implemented and unit-tested; it is NOT + // wired here yet because restart-in-place is unsafe against the + // guard-less pinned rev (see `WalletBackend::stop_in_place` SAFETY note). if let Some(backend) = self.take_wallet_backend() { // Capture the exact persister path this backend opened, so the // release barrier below probes the same file `WalletBackend::new` @@ -1690,6 +1712,76 @@ mod tests { second.shutdown().await; } + /// Restart-in-place reconnect: `WalletBackend::stop_in_place()` keeps the + /// backend (and its `Arc`) wired, so the reconnect reuses + /// the SAME instance — the persister DB is never closed/reopened, so + /// `AlreadyOpen` is impossible by construction (no barrier needed). + /// + /// Asserts: same backend pointer across disconnect→connect (reuse, not + /// rebuild); `is_started()` cleared by `stop_in_place()` then re-set by the + /// reconnect's `start()` (latch + gate re-armed); reconnect returns `Ok` + /// with no `AlreadyOpen`. + /// + /// IGNORED: restart-in-place re-`start()`s the SAME `platform_address_sync` + /// instance, which is race-free only once the upstream + /// `background_generation` guard lands there (dashpay/platform#3828, branch + /// `fix/wallet-core-derived-rehydration`). Against the guard-less pinned rev + /// this can leak an uncancellable / duplicate platform-address loop, so this + /// test must run only after that fix is in the pinned rev. Un-ignore it + /// together with wiring `stop_in_place` into `stop_spv` (see the TODO in + /// `stop_spv`). + #[ignore = "restart-in-place is safe only against a platform rev that carries the \ + platform_address_sync background_generation guard (dashpay/platform#3828); \ + un-ignore when stop_spv is flipped to restart-in-place"] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reconnect_restart_in_place_reuses_backend() { + let _reopen_guard = backend_reopen_lock().await; + + let (ctx, sender, _tmp) = offline_testnet_context(); + + ctx.ensure_wallet_backend_and_start_spv(sender.clone()) + .await + .expect("initial start should wire then start offline"); + let first = ctx.wallet_backend().expect("backend wired after start"); + assert!(first.is_started(), "initial start must latch the backend"); + let first_ptr = Arc::as_ptr(&first); + drop(first); + + // Stop IN PLACE: the backend stays wired (slot not taken). + let backend = ctx.wallet_backend().expect("backend still wired"); + backend.stop_in_place().await; + assert!( + !backend.is_started(), + "stop_in_place must re-arm the start latch (is_started == false)" + ); + assert!( + ctx.wallet_backend().is_ok(), + "stop_in_place must keep the backend wired (NOT take it)" + ); + drop(backend); + + // Reconnect: `ensure_wallet_backend` fast-paths on the populated slot + // (no `WalletBackend::new`, no `SqlitePersister::open`), so the same + // instance restarts — structurally immune to `AlreadyOpen`. + ctx.ensure_wallet_backend_and_start_spv(sender) + .await + .expect("reconnect should restart the SAME backend in place"); + let second = ctx + .wallet_backend() + .expect("backend still wired after reconnect"); + assert_eq!( + first_ptr, + Arc::as_ptr(&second), + "restart-in-place must REUSE the same backend, not rebuild it" + ); + assert!( + second.is_started(), + "reconnect must restart chain sync on the reused backend's re-armed latch" + ); + + second.shutdown().await; + } + /// Deterministic gate for the B-2 reconnect fix: `await_persister_released` /// must BLOCK while the persister path is still held in the process-global /// open-path REGISTRY, and return only once the holder drops it — at which diff --git a/src/wallet_backend/coordinator_gate.rs b/src/wallet_backend/coordinator_gate.rs index 896d14d8b..56ee6ab0a 100644 --- a/src/wallet_backend/coordinator_gate.rs +++ b/src/wallet_backend/coordinator_gate.rs @@ -14,10 +14,12 @@ //! * not ready yet → the `EventBridge` calls [`CoordinatorGate::on_masternodes_ready`] //! when the masternode list reaches `Synced`, which fires the armed action. //! -//! A fresh backend (and a fresh gate) is built on every reconnect, so the latch -//! re-arms naturally — there is no cross-reconnect state to clear here. +//! The drop+rebuild reconnect path builds a fresh backend (and a fresh gate) +//! each time, so the latch re-arms naturally. The restart-in-place reconnect +//! path reuses the same backend and gate instead; it calls +//! [`CoordinatorGate::reset`] to re-arm the gate for the next `start()`. -use std::sync::OnceLock; +use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; /// The one-shot start action: starts the platform-address and identity sync @@ -32,8 +34,11 @@ pub(super) struct CoordinatorGate { /// Whether the SPV masternode list has finished syncing. Set by the /// `EventBridge`; mirrors `ConnectionStatus::masternodes_ready`. masternodes_ready: AtomicBool, - /// The start action, installed once by `WalletBackend::start`. - action: OnceLock, + /// The start action, installed by `WalletBackend::start`. Held in a + /// `Mutex>` (not a `OnceLock`) so [`Self::reset`] can clear it + /// for a restart-in-place reconnect, which re-arms this same gate rather + /// than building a fresh one. + action: Mutex>, /// Single-winner guard so the action runs exactly once across the two /// concurrent fire paths (`arm` and `on_masternodes_ready`). fired: AtomicBool, @@ -43,7 +48,7 @@ impl std::fmt::Debug for CoordinatorGate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CoordinatorGate") .field("masternodes_ready", &self.masternodes_ready()) - .field("armed", &self.action.get().is_some()) + .field("armed", &self.action.lock().is_ok_and(|a| a.is_some())) .field("fired", &self.fired.load(Ordering::SeqCst)) .finish() } @@ -67,9 +72,16 @@ impl CoordinatorGate { /// [`Self::on_masternodes_ready`] (case (b)). A second arm is ignored — the /// action slot is write-once. pub(super) fn arm(&self, action: StartAction) { - if self.action.set(action).is_err() { - tracing::debug!("CoordinatorGate already armed; ignoring second arm"); - return; + { + let mut slot = self + .action + .lock() + .expect("coordinator gate action mutex poisoned"); + if slot.is_some() { + tracing::debug!("CoordinatorGate already armed; ignoring second arm"); + return; + } + *slot = Some(action); } self.try_fire(); } @@ -95,7 +107,11 @@ impl CoordinatorGate { if self.fired.swap(true, Ordering::SeqCst) { return; } - if let Some(action) = self.action.get() { + let slot = self + .action + .lock() + .expect("coordinator gate action mutex poisoned"); + if let Some(action) = slot.as_ref() { tracing::info!("Masternode list synced; starting Platform sync coordinators"); action(); } @@ -104,7 +120,26 @@ impl CoordinatorGate { /// Pure decision: the action may fire when masternodes are ready, an action /// is armed, and it has not fired yet. Side-effect-free, unit-testable. fn should_fire(&self) -> bool { - self.masternodes_ready() && self.action.get().is_some() && !self.has_fired() + self.masternodes_ready() + && self.action.lock().is_ok_and(|a| a.is_some()) + && !self.has_fired() + } + + /// Clear the gate so a restart-in-place reconnect can re-arm it. + /// + /// Drops the installed action, clears the single-winner `fired` flag, and + /// resets `masternodes_ready` to `false`. The last is mandatory: a restart + /// rebuilds the SPV session, which re-syncs the masternode list from + /// scratch, so the coordinators must wait for a fresh `Synced` signal + /// before firing — starting them against a not-yet-synced masternode list + /// fires proof-verifying DAPI calls that get every queried node banned. + pub(super) fn reset(&self) { + *self + .action + .lock() + .expect("coordinator gate action mutex poisoned") = None; + self.fired.store(false, Ordering::SeqCst); + self.masternodes_ready.store(false, Ordering::SeqCst); } } @@ -199,7 +234,7 @@ mod tests { // Armed, not ready → no. let calls = Arc::new(AtomicUsize::new(0)); let gate = CoordinatorGate::default(); - let _ = gate.action.set(counting_action(&calls)); + *gate.action.lock().unwrap() = Some(counting_action(&calls)); assert!(!gate.should_fire()); // Armed and ready, not fired → yes. @@ -234,6 +269,47 @@ mod tests { ); } + /// Restart-in-place: [`CoordinatorGate::reset`] must re-arm the gate so the + /// SAME instance fires the coordinators again on a reconnect — clearing the + /// installed action, the `fired` latch, and `masternodes_ready`. + #[test] + fn reset_re_arms_gate_for_restart_in_place() { + let calls = Arc::new(AtomicUsize::new(0)); + let gate = CoordinatorGate::default(); + + // First arm + ready → fires once. + gate.arm(counting_action(&calls)); + gate.on_masternodes_ready(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(gate.has_fired()); + assert!(gate.masternodes_ready()); + + // Reset clears action, the fired latch, and masternodes_ready. + gate.reset(); + assert!(!gate.has_fired(), "reset must clear the fired latch"); + assert!( + !gate.masternodes_ready(), + "reset must clear masternodes_ready so coordinators re-wait for a fresh sync" + ); + + // A ready signal after reset with nothing re-armed must not fire. + gate.on_masternodes_ready(); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "ready with no action armed after reset must not fire" + ); + + // Re-arm: masternodes are ready again, so the action fires a SECOND + // time — the gate is reusable across a restart-in-place reconnect. + gate.arm(counting_action(&calls)); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "re-arming a reset gate must fire the coordinators again" + ); + } + /// Regression guard for the WEAK capture at `WalletBackend::start`. /// /// The gate is reachable from the `EventBridge`, which the long-lived SPV diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index c39dbeb5e..4bca10afc 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -132,6 +132,14 @@ impl StartLatch { fn is_started(&self) -> bool { self.0.load(Ordering::SeqCst) } + + /// Re-arm the latch so [`WalletBackend::start`] can spawn the run loop + /// again on a reused backend. Used by the restart-in-place teardown + /// ([`WalletBackend::stop_in_place`]); without it the one-shot + /// `try_begin` would refuse the reconnect's start. + fn reset(&self) { + self.0.store(false, Ordering::SeqCst); + } } /// Default BIP-44 account index for wallet receive/send operations. DET has @@ -1076,6 +1084,58 @@ impl WalletBackend { self.inner.pwm.shutdown().await; } + /// Stop chain sync **in place**, keeping this backend (and its + /// `Arc`) alive so a same-network reconnect can restart + /// on the SAME instance via [`Self::start`] — the persister DB is never + /// closed/reopened, so the reconnect cannot hit + /// `WalletStorageError::AlreadyOpen` (the root of the B-2 bug) by + /// construction. + /// + /// Unlike [`Self::shutdown`], this deliberately does **not** call + /// `pwm.shutdown()`: that cancels and joins the wallet-event adapter task, + /// which has no re-create path, so a subsequent restart would lose event + /// processing. Instead it stops the restartable pieces only: + /// + /// 1. `pwm.spv().stop()` — stops/joins the SPV run loop (releasing the SPV + /// storage advisory lock) while leaving the `SpvRuntime` and its + /// `PlatformEventManager` in place for the next `spawn_in_background`. + /// 2. `quiesce()` the three sync coordinators (cancel + drain the in-flight + /// pass) via their `Arc` accessors — NOT `pwm.shutdown()` — so the event + /// adapter keeps running. + /// 3. Re-arm the DET start gates ([`StartLatch::reset`] + + /// [`CoordinatorGate::reset`]) so the reconnect's `start()` spawns the + /// run loop again and re-fires the coordinators once masternodes re-sync. + /// + /// SPV is stopped before the coordinators (producer before consumers), + /// mirroring [`Self::shutdown`]'s ordering. + /// + /// SAFETY (restart-in-place vs the upstream coordinators): restarting the + /// SAME coordinator instance is only race-free once every coordinator + /// clears its cancel slot under a generation guard. `identity_sync` and + /// `shielded_sync` already do; `platform_address_sync` does NOT in the + /// pinned platform rev, so a rapid reconnect can leak an uncancellable / + /// duplicate platform-address loop. This method is therefore NOT yet the + /// live reconnect path — see the activation TODO in + /// [`AppContext::stop_spv`](crate::context::AppContext::stop_spv). + pub async fn stop_in_place(&self) { + // 1. Stop the SPV run loop first (producer), keeping the SpvRuntime. + if let Err(e) = self.inner.pwm.spv().stop().await { + tracing::warn!( + error = ?e, + "SPV run loop did not stop cleanly during stop_in_place; continuing" + ); + } + // 2. Quiesce the coordinators (consumers) directly — do NOT call + // `pwm.shutdown()`, which would also tear down the non-restartable + // wallet-event adapter. + self.inner.pwm.platform_address_sync_arc().quiesce().await; + self.inner.pwm.identity_sync_arc().quiesce().await; + self.inner.pwm.shielded_sync_arc().quiesce().await; + // 3. Re-arm the DET start gates for the next start() on this backend. + self.inner.start_latch.reset(); + self.inner.coordinator_gate.reset(); + } + /// Number of wallets currently registered with the backend. pub async fn wallet_count(&self) -> usize { self.inner.pwm.wallet_ids().await.len() @@ -3123,6 +3183,21 @@ mod tests { assert!(latch.is_started(), "latch stays started"); } + /// Restart-in-place: `reset()` re-arms the one-shot latch so `try_begin` + /// wins again on a reused backend (the reconnect's `start()`). + #[test] + fn start_latch_reset_allows_restart() { + let latch = StartLatch::default(); + assert!(latch.try_begin(), "first begin wins"); + assert!(!latch.try_begin(), "second begin refused while latched"); + assert!(latch.is_started()); + + latch.reset(); + assert!(!latch.is_started(), "reset must clear the latch"); + assert!(latch.try_begin(), "begin wins again after reset"); + assert!(!latch.try_begin(), "and re-latches one-shot after reset"); + } + /// Concurrent callers race to a single winner — exactly one thread sees /// `try_begin() == true`. Pins the atomic-swap contract that prevents two /// SPV run loops from racing against the same data directory. From e084b7a6e61f07c134eafe98caaef7a5087299f6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:16:09 +0200 Subject: [PATCH 02/24] feat(context): activate SPV/coordinator restart-in-place; retire B-2 barrier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the same-network disconnect/reconnect to restart-in-place (Option A) and removes the interim B-2 release barrier. Builds on the machinery landed in the previous commit (re-armable CoordinatorGate, re-runnable StartLatch, WalletBackend::stop_in_place). - stop_spv (src/context/wallet_lifecycle.rs) no longer takes + drops the WalletBackend. It keeps the backend (and its Arc) wired in the AppContext slot and calls backend.stop_in_place().await — stop the SPV run loop + quiesce the 3 coordinators, re-arm the start latch + gate — then settles the indicator. The persister DB is never closed/reopened, so the reconnect cannot hit WalletStorageError::AlreadyOpen by construction. - Reconnect reuses the SAME backend: ensure_wallet_backend fast-paths on the populated slot (no WalletBackend::new, no SqlitePersister::open) and start() re-runs on the re-armed latch. - Deleted the B-2 await_persister_released barrier + its offline test: under keep-alive the persister is never released, so the barrier's open-probe would itself hit AlreadyOpen and spin to its 5s timeout every reconnect — it is mutually exclusive with restart-in-place. - Removed AppContext::take_wallet_backend (its only caller was the old stop_spv). - Network SWITCH is unaffected: it uses a per-network context with a different persister path and never calls stop_spv. Tests: - reconnect_restart_in_place_reuses_backend (now runs, not ignored): drives the real stop_spv -> ensure_wallet_backend_and_start_spv path; asserts the same backend pointer across disconnect->connect, latch/gate re-armed, no AlreadyOpen. The Q3 timing race is NOT asserted here (see below). - stop_spv_unwires... renamed to stop_spv_in_place_keeps_backend_and_disconnects_indicator and asserts the backend stays wired + latch re-armed. - Removed the obsolete rebuild test reconnect_after_stop_rebuilds_fresh_backend_and_restarts. TODO(dashpay/platform#3828): restart-in-place RUNTIME safety depends on the platform_address_sync background_generation guard being in the pinned rev. platform_address_sync (rev 925b109) clears its cancel slot unconditionally, so a rapid reconnect can leak an uncancellable platform-address sync loop (Q3). identity_sync and shielded_sync already carry the guard. The DET code compiles and the start/stop/quiesce/accessor APIs all exist on 925b109 — only the Q3 timing race is unsafe until the guard lands. Finalize once it merges on branch fix/wallet-core-derived-rehydration: cargo update -p platform-wallet -p platform-wallet-storage -p dash-sdk then re-run live reconnect validation. (Not done here — the user coordinates the upstream fix.) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/context/mod.rs | 9 - src/context/wallet_lifecycle.rs | 365 ++++++-------------------------- src/wallet_backend/mod.rs | 12 +- 3 files changed, 67 insertions(+), 319 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index 8a31c14a9..cd8710cd0 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -862,15 +862,6 @@ impl AppContext { .ok_or(TaskError::WalletBackendNotYetWired) } - /// Unwire the wallet seam, returning the previously wired backend if any. - /// - /// The next [`Self::ensure_wallet_backend`] call rebuilds a fresh backend. - /// Used by the disconnect chokepoint ([`Self::stop_spv`]) to tear the seam - /// down so a subsequent Connect starts from a clean, restartable state. - pub(crate) fn take_wallet_backend(&self) -> Option> { - self.wallet_backend.swap(None) - } - /// Install the interactive secret-prompt host (the egui host in the GUI). /// /// Must be called **before** [`Self::ensure_wallet_backend`] builds the diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 03a4d53fb..863d6f8b3 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -377,41 +377,26 @@ impl AppContext { self.connection_status.set_spv_status(SpvStatus::Stopping); self.connection_status.refresh_state(); - // CURRENT (Q3-safe) reconnect model: drop + rebuild. `take_wallet_backend` - // unwires the backend, `shutdown()` stops SPV + coordinators, the - // backend is dropped, and `await_persister_released` waits out the - // detached coordinator threads before the next reconnect reopens the - // persister. Each reconnect builds FRESH coordinator instances, so the - // upstream `platform_address_sync` restart race (Q3) cannot occur. + // Restart-in-place disconnect: keep the `WalletBackend` (and its + // `Arc`) wired in the AppContext slot — do NOT unwire + // or drop it. `stop_in_place` stops the SPV run loop and + // quiesces the three coordinators while leaving the backend + persister + // alive, and re-arms the start latch + coordinator gate so the next + // same-network Connect restarts on the SAME instance (the reconnect + // reuses it via `ensure_wallet_backend`'s populated-slot fast path). + // Because the persister DB is never closed/reopened, the reconnect + // cannot hit `WalletStorageError::AlreadyOpen` — by construction, so no + // release barrier is needed. (A network SWITCH is a different path: it + // uses a per-network context with a different persister and is + // unaffected by this.) // - // TODO: enable restart-in-place once dashpay/platform#3828 lands the - // `platform_address_sync` `background_generation` guard in the pinned - // `fix/wallet-core-derived-rehydration` branch and we `cargo update` - // the platform crates to a rev that contains it. The flip is: - // - replace this `take_wallet_backend()` + `shutdown()` + drop + - // `await_persister_released` block with - // `if let Ok(backend) = self.wallet_backend() { backend.stop_in_place().await; }` - // (keeps the backend + persister wired; reconnect reuses the SAME - // instance via `ensure_wallet_backend`'s populated-slot fast path), - // - delete `await_persister_released` and its offline test, - // - keep the `set_masternodes_ready(false)` + indicator flips below. - // The machinery (`WalletBackend::stop_in_place`, `CoordinatorGate::reset`, - // `StartLatch::reset`) is already implemented and unit-tested; it is NOT - // wired here yet because restart-in-place is unsafe against the - // guard-less pinned rev (see `WalletBackend::stop_in_place` SAFETY note). - if let Some(backend) = self.take_wallet_backend() { - // Capture the exact persister path this backend opened, so the - // release barrier below probes the same file `WalletBackend::new` - // reopens on reconnect — no hardcoded path, no drift. - let persister_path = backend.spv_storage_dir().join("platform-wallet.sqlite"); - backend.shutdown().await; - // Drop the last in-scope `Arc` so `Inner` (and the - // persister `Arc`s it owns directly) release synchronously, then - // wait out the detached upstream coordinator threads that may still - // hold transitive persister `Arc`s for a short while — see - // `await_persister_released`. - drop(backend); - self.await_persister_released(&persister_path).await; + // TODO(dashpay/platform#3828): restart-in-place runtime safety depends on the + // platform_address_sync background_generation guard being in the pinned rev. + // Until that lands + we cargo update, a rapid reconnect can leak an uncancellable + // platform-address sync loop (Q3). Finalize = cargo update -p platform-wallet + // -p platform-wallet-storage -p dash-sdk, then re-run live reconnect validation. + if let Ok(backend) = self.wallet_backend() { + backend.stop_in_place().await; } self.connection_status.set_spv_status(SpvStatus::Stopped); @@ -429,93 +414,6 @@ impl AppContext { self.connection_status.refresh_state(); } - /// Bounded barrier that blocks [`Self::stop_spv`] until the upstream - /// platform-wallet persister at `persister_path` is fully released back to - /// the process-global open-path registry, so the next reconnect can reopen - /// it. - /// - /// ## Why this exists - /// - /// `SqlitePersister` refuses a second in-process open of the same path with - /// `WalletStorageError::AlreadyOpen`, tracked in a process-global registry - /// that is cleared **only** by `impl Drop for SqlitePersister`. On - /// disconnect we drop the [`WalletBackend`] (releasing the persister `Arc`s - /// it owns directly), and [`WalletBackend::shutdown`] joins the SPV run - /// loop — but `PlatformWalletManager::shutdown` only `quiesce()`s the three - /// sync coordinators (`identity_sync`, `platform_address_sync`, - /// `shielded_sync`). `quiesce()` is cancel-and-drain, **not** join: each - /// coordinator runs on a detached `std::thread` that is never joined and - /// transitively holds an `Arc`. So the persister's `Drop` - /// (and the registry release) is deferred until those threads wind down — - /// shortly *after* `shutdown()` returns. Without this barrier an immediate - /// reconnect calls `WalletBackend::new` → `SqlitePersister::open` on the - /// still-registered path and fails with `AlreadyOpen` (the user's - /// Disconnect → Connect bug). - /// - /// ## What it does - /// - /// Probe the path by opening it ourselves: a successful open proves the - /// registry entry is gone (the last coordinator thread dropped its - /// persister `Arc`), so we drop the probe **immediately** — re-freeing the - /// path — and return. While the coordinator threads are still winding down - /// the open returns `AlreadyOpen`; we back off and retry. The loop is - /// bounded so a stuck thread or an unrelated open failure can never block - /// disconnect forever; on timeout or any non-`AlreadyOpen` error we log and - /// return (best-effort — a later reconnect surfaces a real open error to - /// the user, which is strictly better than hanging the disconnect path). - /// - /// Runs on the disconnect path only and never touches the connect path. - /// - // TODO(upstream rs-platform-wallet): this barrier is a workaround for the - // three sync coordinators (`manager/identity_sync.rs`, - // `manager/platform_address_sync.rs`, `manager/shielded_sync.rs`) running - // on detached, non-joinable `std::thread`s whose `quiesce()` is - // cancel-and-drain, not join. The durable fix is upstream: make `quiesce()` - // await actual thread exit (keep the `JoinHandle` / signal a oneshot at - // thread end) so `PlatformWalletManager::shutdown()` returns only after - // every coordinator has dropped its `Arc`. Once that lands - // upstream this poll loop can be removed. - async fn await_persister_released(&self, persister_path: &Path) { - use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; - - const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20); - const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - - let deadline = std::time::Instant::now() + TIMEOUT; - loop { - match SqlitePersister::open(SqlitePersisterConfig::new(persister_path)) { - Ok(probe) => { - // Path is free. Drop the probe IMMEDIATELY so it re-frees - // the registry entry before the reconnect reopens the path. - drop(probe); - return; - } - Err(WalletStorageError::AlreadyOpen { .. }) => { - if std::time::Instant::now() >= deadline { - tracing::warn!( - path = %persister_path.display(), - "Platform-wallet persister was not released within the disconnect timeout; \ - proceeding anyway — a reconnect may briefly fail to reopen it" - ); - return; - } - tokio::time::sleep(POLL_INTERVAL).await; - } - Err(other) => { - // An unrelated open failure (forward-version db, IO, etc.). - // Not this barrier's concern — don't spin on it; the - // reconnect path surfaces such errors to the user properly. - tracing::warn!( - path = %persister_path.display(), - error = ?other, - "Probe open during the disconnect barrier failed for an unrelated reason; proceeding" - ); - return; - } - } - } - } - /// Persist a wallet to the database and register it in the in-memory map. /// /// This is the single entry point for adding a wallet to the system. @@ -1555,11 +1453,12 @@ mod tests { } /// The Disconnect chokepoint must produce a *visible* state change: after a - /// successful start, `stop_spv` unwires the backend and settles the - /// indicator on `Stopped` / `Disconnected`. Regression guard ensuring the - /// Disconnect button drives the overall state out of its active value. + /// successful start, `stop_spv` stops chain sync IN PLACE — keeping the + /// backend wired for a restart — and settles the indicator on `Stopped` / + /// `Disconnected`. Regression guard ensuring the Disconnect button drives + /// the overall state out of its active value while preserving the backend. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn stop_spv_unwires_backend_and_disconnects_indicator() { + async fn stop_spv_in_place_keeps_backend_and_disconnects_indicator() { use crate::context::connection_status::OverallConnectionState; let (ctx, sender, _tmp) = offline_testnet_context(); @@ -1577,9 +1476,12 @@ mod tests { ctx.stop_spv().await; + let backend = ctx + .wallet_backend() + .expect("stop_spv must KEEP the backend wired for restart-in-place (NOT unwire it)"); assert!( - ctx.wallet_backend().is_err(), - "stop_spv must unwire the backend so the next Connect rebuilds it" + !backend.is_started(), + "stop_spv must re-arm the start latch so the next Connect can restart" ); assert!( !ctx.connection_status().masternodes_ready(), @@ -1623,32 +1525,28 @@ mod tests { ); } - /// Regression guard for the Connect → Disconnect → Connect bug: - /// `WalletBackend::shutdown` must stop the SPV background task so the - /// transitive `Arc` it holds is released *synchronously* - /// before `shutdown()` returns. Without that, the upstream process-global - /// `REGISTRY` keeps the persister path registered and the reconnect's - /// `SqlitePersister::open` fails with `WalletStorageError::AlreadyOpen`. + /// Restart-in-place reconnect: a same-network Disconnect → Connect keeps the + /// SAME `WalletBackend` (and its `Arc`) wired, so the + /// persister DB is never closed/reopened and `AlreadyOpen` is impossible by + /// construction — no release barrier needed. Drives the real production + /// path: `stop_spv()` (in-place) then `ensure_wallet_backend_and_start_spv()`. /// - /// Offline scope: asserts deterministic rebuild + rewire + restart — - /// a fresh backend instance, wired again, with `is_started()` set on the - /// new instance (its fresh latch fired). The `Syncing`/`Running` indicator - /// transition is network-driven and tested by the backend-e2e suite. + /// Validated offline (passes now): the backend pointer is identical across + /// disconnect→connect (reuse, not rebuild); `is_started()` is cleared by + /// `stop_spv` and re-set by the reconnect (latch + gate re-armed); the + /// reconnect returns `Ok` with no `AlreadyOpen`. /// - /// Limitation: offline, the SPV task and the upstream sync-coordinator - /// threads may exit on their own fast enough that the path is free even - /// without the barrier, so this end-to-end test is a smoke-check, not a - /// deterministic gate. The deterministic gate on the release mechanism - /// itself is `await_persister_released_waits_for_registry_release` below; - /// the authoritative end-to-end guard is the online reconnect test in the - /// backend-e2e suite. + /// NOT validated here (needs the upstream guard): the Q3 timing race — a + /// rapid restart of the SAME `platform_address_sync` instance can leak an + /// uncancellable / duplicate platform-address loop until the + /// `background_generation` guard lands in the pinned rev + /// (dashpay/platform#3828). This test asserts the DET-level reuse/restart + /// contract, not the absence of that upstream race; live reconnect + /// validation against the guarded rev covers the latter. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn reconnect_after_stop_rebuilds_fresh_backend_and_restarts() { + async fn reconnect_restart_in_place_reuses_backend() { use crate::context::connection_status::OverallConnectionState; - use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; - // Serialize this reopen-same-path test against any sibling that races on - // the upstream single-open advisory lock; see `backend_reopen_lock`. let _reopen_guard = backend_reopen_lock().await; let (ctx, sender, _tmp) = offline_testnet_context(); @@ -1658,110 +1556,33 @@ mod tests { .expect("initial start should wire then start offline"); let first = ctx.wallet_backend().expect("backend wired after start"); assert!(first.is_started(), "initial start must latch the backend"); - - // Capture the raw pointer for the fresh-backend identity check below, - // and the persister path to assert the REGISTRY entry is cleared. let first_ptr = Arc::as_ptr(&first); - let persister_path = first.spv_storage_dir().join("platform-wallet.sqlite"); drop(first); + // Disconnect IN PLACE via the production chokepoint: the backend stays + // wired (slot not taken), the start latch is re-armed, the indicator + // settles on Disconnected. ctx.stop_spv().await; + let after_stop = ctx + .wallet_backend() + .expect("stop_spv must KEEP the backend wired for restart-in-place"); assert!( - ctx.wallet_backend().is_err(), - "precondition: stop_spv unwired the backend" + !after_stop.is_started(), + "stop_spv must re-arm the start latch (is_started == false)" ); assert_eq!( ctx.connection_status().overall_state(), OverallConnectionState::Disconnected, - "precondition: disconnected before reconnect" - ); - - // After `stop_spv()` returns the persister path must be free: the - // B-fix `shutdown()` joins the SPV run loop, then `stop_spv` drops the - // backend and runs `await_persister_released`, which blocks until the - // detached upstream coordinator threads have dropped their transitive - // `Arc` and the process-global REGISTRY entry is gone. - // - // Assert the path is free *immediately* after `stop_spv()` returns. - let reopen = SqlitePersister::open(SqlitePersisterConfig::new(&persister_path)); - assert!( - reopen.is_ok(), - "persister path must be released synchronously after stop_spv() — \ - if this is AlreadyOpen the SPV task still holds the path open: {:?}", - reopen.err() - ); - // Drop the probe handle before the reconnect re-opens the same path. - drop(reopen); - - ctx.ensure_wallet_backend_and_start_spv(sender) - .await - .expect("reconnect should wire then start a fresh backend offline"); - - let second = ctx - .wallet_backend() - .expect("backend must be wired again after reconnect"); - assert!( - first_ptr != Arc::as_ptr(&second), - "reconnect must rebuild a fresh backend, not revive the dropped one" - ); - assert!( - second.is_started(), - "reconnect must restart chain sync on the fresh backend's latch" - ); - - second.shutdown().await; - } - - /// Restart-in-place reconnect: `WalletBackend::stop_in_place()` keeps the - /// backend (and its `Arc`) wired, so the reconnect reuses - /// the SAME instance — the persister DB is never closed/reopened, so - /// `AlreadyOpen` is impossible by construction (no barrier needed). - /// - /// Asserts: same backend pointer across disconnect→connect (reuse, not - /// rebuild); `is_started()` cleared by `stop_in_place()` then re-set by the - /// reconnect's `start()` (latch + gate re-armed); reconnect returns `Ok` - /// with no `AlreadyOpen`. - /// - /// IGNORED: restart-in-place re-`start()`s the SAME `platform_address_sync` - /// instance, which is race-free only once the upstream - /// `background_generation` guard lands there (dashpay/platform#3828, branch - /// `fix/wallet-core-derived-rehydration`). Against the guard-less pinned rev - /// this can leak an uncancellable / duplicate platform-address loop, so this - /// test must run only after that fix is in the pinned rev. Un-ignore it - /// together with wiring `stop_in_place` into `stop_spv` (see the TODO in - /// `stop_spv`). - #[ignore = "restart-in-place is safe only against a platform rev that carries the \ - platform_address_sync background_generation guard (dashpay/platform#3828); \ - un-ignore when stop_spv is flipped to restart-in-place"] - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn reconnect_restart_in_place_reuses_backend() { - let _reopen_guard = backend_reopen_lock().await; - - let (ctx, sender, _tmp) = offline_testnet_context(); - - ctx.ensure_wallet_backend_and_start_spv(sender.clone()) - .await - .expect("initial start should wire then start offline"); - let first = ctx.wallet_backend().expect("backend wired after start"); - assert!(first.is_started(), "initial start must latch the backend"); - let first_ptr = Arc::as_ptr(&first); - drop(first); - - // Stop IN PLACE: the backend stays wired (slot not taken). - let backend = ctx.wallet_backend().expect("backend still wired"); - backend.stop_in_place().await; - assert!( - !backend.is_started(), - "stop_in_place must re-arm the start latch (is_started == false)" + "stop_spv must settle the indicator on Disconnected" ); assert!( - ctx.wallet_backend().is_ok(), - "stop_in_place must keep the backend wired (NOT take it)" + !ctx.connection_status().masternodes_ready(), + "stop_spv must re-arm the quorum gate (masternodes_ready == false)" ); - drop(backend); + drop(after_stop); // Reconnect: `ensure_wallet_backend` fast-paths on the populated slot - // (no `WalletBackend::new`, no `SqlitePersister::open`), so the same + // (no `WalletBackend::new`, no `SqlitePersister::open`), so the SAME // instance restarts — structurally immune to `AlreadyOpen`. ctx.ensure_wallet_backend_and_start_spv(sender) .await @@ -1782,74 +1603,6 @@ mod tests { second.shutdown().await; } - /// Deterministic gate for the B-2 reconnect fix: `await_persister_released` - /// must BLOCK while the persister path is still held in the process-global - /// open-path REGISTRY, and return only once the holder drops it — at which - /// point the path is reopenable. - /// - /// This watches the RIGHT object — the `SqlitePersister` registry — and is - /// deterministic (no reliance on coordinator-thread exit timing): we hold - /// the persister ourselves, prove a concurrent open is refused with - /// `AlreadyOpen`, prove the barrier is still waiting while we hold it, then - /// release and prove the barrier unblocks and the path is free. A barrier - /// that returned eagerly (the pre-fix behaviour) would fail the - /// "still-waiting-while-held" assertion. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn await_persister_released_waits_for_registry_release() { - use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; - - // Serialize against any sibling that races on the upstream single-open - // registry; the path here is unique to a fresh tempdir, but the global - // REGISTRY mutex is shared process-wide. - let _reopen_guard = backend_reopen_lock().await; - - let (ctx, _sender, _tmp) = offline_testnet_context(); - - let probe_dir = tempfile::tempdir().expect("create probe tempdir"); - let path = probe_dir.path().join("platform-wallet.sqlite"); - - // Hold the persister open: the path is now registered, so any second - // in-process open of it is refused with `AlreadyOpen`. - let held = SqlitePersister::open(SqlitePersisterConfig::new(&path)) - .expect("first open should succeed and register the path"); - assert!( - matches!( - SqlitePersister::open(SqlitePersisterConfig::new(&path)), - Err(WalletStorageError::AlreadyOpen { .. }) - ), - "precondition: a held path must refuse a second open with AlreadyOpen" - ); - - // Run the barrier concurrently. It must NOT complete while `held` keeps - // the path registered. - let barrier_ctx = Arc::clone(&ctx); - let barrier_path = path.clone(); - let barrier = - tokio::spawn(async move { barrier_ctx.await_persister_released(&barrier_path).await }); - - // Give the barrier several poll cycles (POLL_INTERVAL is 20ms). Because - // `held` still pins the path, every probe open returns AlreadyOpen, so - // the barrier must still be looping — never finished. - tokio::time::sleep(std::time::Duration::from_millis(150)).await; - assert!( - !barrier.is_finished(), - "barrier must keep waiting while the persister path is still held" - ); - - // Release the path. The barrier's next probe open succeeds, it drops - // the probe, and returns. - drop(held); - - tokio::time::timeout(std::time::Duration::from_secs(2), barrier) - .await - .expect("barrier must return promptly once the path is released") - .expect("barrier task must not panic"); - - // The barrier dropped its probe before returning, so the path is free. - SqlitePersister::open(SqlitePersisterConfig::new(&path)) - .expect("path must be reopenable after the barrier returns"); - } - /// QA-007: a failure at the (fallible) wiring step must surface — the /// chokepoint returns `Err` AND flips the SPV indicator to `Error`, so the /// user does not silently fall back to `Disconnected` with no feedback. diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 4bca10afc..5dbe7f322 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -1109,14 +1109,18 @@ impl WalletBackend { /// SPV is stopped before the coordinators (producer before consumers), /// mirroring [`Self::shutdown`]'s ordering. /// + /// This is the live same-network disconnect path + /// ([`AppContext::stop_spv`](crate::context::AppContext::stop_spv) calls it). + /// /// SAFETY (restart-in-place vs the upstream coordinators): restarting the /// SAME coordinator instance is only race-free once every coordinator /// clears its cancel slot under a generation guard. `identity_sync` and /// `shielded_sync` already do; `platform_address_sync` does NOT in the - /// pinned platform rev, so a rapid reconnect can leak an uncancellable / - /// duplicate platform-address loop. This method is therefore NOT yet the - /// live reconnect path — see the activation TODO in - /// [`AppContext::stop_spv`](crate::context::AppContext::stop_spv). + /// pinned platform rev, so until the upstream guard lands + /// (dashpay/platform#3828) a rapid reconnect can leak an uncancellable / + /// duplicate platform-address loop — see the `TODO(dashpay/platform#3828)` + /// at the `stop_spv` call site for the finalize step (`cargo update` the + /// platform crates once the guard is in the pinned rev). pub async fn stop_in_place(&self) { // 1. Stop the SPV run loop first (producer), keeping the SpvRuntime. if let Err(e) = self.inner.pwm.spv().stop().await { From 02c198b408dc6b639f0130cea742cecc9b000e60 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:28:37 +0200 Subject: [PATCH 03/24] feat(wallet-backend): finalize restart-in-place; bump platform rev for platform_address_sync guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restart-in-place reconnect (stop_spv keeps the WalletBackend + Arc alive and restarts SPV + the 3 coordinators on the SAME instance; B-2 barrier retired) was wired in e084b7a6 but its RUNTIME safety depended on the upstream platform_address_sync background_generation guard, which the pinned platform rev 925b109 lacked (Q3: a rapid reconnect could leak an uncancellable / duplicate platform-address sync loop). That guard has now landed on branch fix/wallet-core-derived-rehydration (dashpay/platform#3828, head b4506492): platform_address_sync now carries a background_generation: AtomicU64 field, bumps it in start(), and gates the exiting thread's `*background_cancel = None` clear on `background_generation.load(Acquire) == my_gen` — matching identity_sync / shielded_sync. Verified in the vendored source. This commit finalizes Option A: - Bump the pinned platform crates 925b109 -> b4506492 (cargo update -p dash-sdk -p platform-wallet -p platform-wallet-storage; Cargo.lock only). - Remove the now-resolved TODO(dashpay/platform#3828) at the stop_spv call site and the conditional "until the guard lands" caveats in stop_in_place's SAFETY doc and the reconnect_restart_in_place_reuses_backend test doc — restated as the guard now being present in the pinned rev. Merges origin/docs/platform-wallet-migration-design (PR #863, blocking progress overlay) were integrated in the preceding merge commit; #863 touched no restart-in-place file, so the integration was conflict-free. Validated in-process (offline): reconnect_restart_in_place_reuses_backend (same backend reused across disconnect->connect, no AlreadyOpen, SPV+coordinators restart, latch/gate re-armed), stop_spv_in_place_keeps_backend_and_disconnects_indicator, reset_re_arms_gate_for_restart_in_place, start_latch_reset_allows_restart. Still requires live network (left #[ignore]d): the backend-e2e B-reconnect connect->disconnect->connect against a synced testnet, which exercises the Q3 timing race the offline tests cannot force. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 62 ++++++++++++++++----------------- src/context/wallet_lifecycle.rs | 24 ++++++------- src/wallet_backend/mod.rs | 15 ++++---- 3 files changed, 50 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58e897235..14887c94c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1872,7 +1872,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "dash-platform-macros", "futures-core", @@ -1974,7 +1974,7 @@ dependencies = [ [[package]] name = "dash-async" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "thiserror 2.0.18", "tokio", @@ -1984,7 +1984,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "dash-async", "dpp", @@ -2095,7 +2095,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "heck", "quote", @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "arc-swap", "async-trait", @@ -2242,7 +2242,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "platform-value", "platform-version", @@ -2253,7 +2253,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2539,7 +2539,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "platform-value", "platform-version", @@ -2550,7 +2550,7 @@ dependencies = [ [[package]] name = "dpp" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "anyhow", "async-trait", @@ -2600,7 +2600,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "proc-macro2", "quote", @@ -2610,7 +2610,7 @@ dependencies = [ [[package]] name = "drive" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2635,7 +2635,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -4522,7 +4522,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.58.0", ] [[package]] @@ -4984,7 +4984,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "platform-value", "platform-version", @@ -5201,7 +5201,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "platform-value", "platform-version", @@ -6343,7 +6343,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "aes", "cbc", @@ -6354,7 +6354,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6363,7 +6363,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "proc-macro2", "quote", @@ -6374,7 +6374,7 @@ dependencies = [ [[package]] name = "platform-value" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "platform-version" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "bincode 2.0.1", "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=fc814983d4d36c6ea049642556b9a31ab8d4dfaa)", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "proc-macro2", "quote", @@ -6415,7 +6415,7 @@ dependencies = [ [[package]] name = "platform-wallet" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "arc-swap", "async-trait", @@ -6447,7 +6447,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "apple-native-keyring-store", "argon2", @@ -6729,7 +6729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.14.0", + "itertools 0.10.5", "log", "multimap", "petgraph", @@ -6750,7 +6750,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -7422,7 +7422,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "backon", "chrono", @@ -7448,7 +7448,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "arc-swap", "dash-async", @@ -8671,7 +8671,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "platform-value", "platform-version", @@ -9463,7 +9463,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "platform-value", "platform-version", @@ -10017,7 +10017,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -10781,7 +10781,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "4.0.0-rc.2" -source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#925b109d88fb416fccfa2675db8850b780858154" +source = "git+https://github.com/dashpay/platform?branch=fix%2Fwallet-core-derived-rehydration#b4506492f7c2ae9015835caf31e15111d0be8a49" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 863d6f8b3..ee14bb335 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -390,11 +390,11 @@ impl AppContext { // uses a per-network context with a different persister and is // unaffected by this.) // - // TODO(dashpay/platform#3828): restart-in-place runtime safety depends on the - // platform_address_sync background_generation guard being in the pinned rev. - // Until that lands + we cargo update, a rapid reconnect can leak an uncancellable - // platform-address sync loop (Q3). Finalize = cargo update -p platform-wallet - // -p platform-wallet-storage -p dash-sdk, then re-run live reconnect validation. + // Restart-in-place runtime safety: all three upstream coordinators clear + // their cancel slot under a `background_generation` guard in the pinned + // platform rev (`platform_address_sync` gained it in b4506492, matching + // `identity_sync`/`shielded_sync`), so a rapid reconnect cannot leak an + // uncancellable / duplicate sync loop (Q3). if let Ok(backend) = self.wallet_backend() { backend.stop_in_place().await; } @@ -1536,13 +1536,13 @@ mod tests { /// `stop_spv` and re-set by the reconnect (latch + gate re-armed); the /// reconnect returns `Ok` with no `AlreadyOpen`. /// - /// NOT validated here (needs the upstream guard): the Q3 timing race — a - /// rapid restart of the SAME `platform_address_sync` instance can leak an - /// uncancellable / duplicate platform-address loop until the - /// `background_generation` guard lands in the pinned rev - /// (dashpay/platform#3828). This test asserts the DET-level reuse/restart - /// contract, not the absence of that upstream race; live reconnect - /// validation against the guarded rev covers the latter. + /// Upstream Q3 race protection now lives in the pinned platform rev: all + /// three coordinators (incl. `platform_address_sync` since b4506492) gate + /// their cancel-slot clear on `background_generation`, so a rapid restart of + /// the SAME instance cannot leak an uncancellable / duplicate loop. This + /// offline test asserts the DET-level reuse/restart contract; it does not + /// itself force the timing race — full live behavior is covered by the + /// network-gated (`#[ignore]`d) backend-e2e B-reconnect test. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reconnect_restart_in_place_reuses_backend() { use crate::context::connection_status::OverallConnectionState; diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 5dbe7f322..7f051ce5c 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -1113,14 +1113,13 @@ impl WalletBackend { /// ([`AppContext::stop_spv`](crate::context::AppContext::stop_spv) calls it). /// /// SAFETY (restart-in-place vs the upstream coordinators): restarting the - /// SAME coordinator instance is only race-free once every coordinator - /// clears its cancel slot under a generation guard. `identity_sync` and - /// `shielded_sync` already do; `platform_address_sync` does NOT in the - /// pinned platform rev, so until the upstream guard lands - /// (dashpay/platform#3828) a rapid reconnect can leak an uncancellable / - /// duplicate platform-address loop — see the `TODO(dashpay/platform#3828)` - /// at the `stop_spv` call site for the finalize step (`cargo update` the - /// platform crates once the guard is in the pinned rev). + /// SAME coordinator instance is race-free because every coordinator clears + /// its cancel slot under a `background_generation` guard in the pinned + /// platform rev — `identity_sync`, `shielded_sync`, and (since b4506492) + /// `platform_address_sync`. Without that guard a lagging old thread could + /// clobber a freshly-installed cancel token, leaking an uncancellable / + /// duplicate loop; the guard makes the stale thread observe the bumped + /// generation and stand down. pub async fn stop_in_place(&self) { // 1. Stop the SPV run loop first (producer), keeping the SpvRuntime. if let Err(e) = self.inner.pwm.spv().stop().await { From 35a4a04152bf624b3eb5a6eb1889410f161372fe Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:29:41 +0200 Subject: [PATCH 04/24] docs(secret-seam): Phase-1 design artifacts (UX disclosure + test case spec) UX disclosure spec by Diziet; 30-case TDD test spec by Marvin. Design reference for the secret-storage raw-SecretBytes seam re-architecture. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- .../01-ux-disclosure.md | 280 +++++++++++ .../02-test-spec.md | 450 ++++++++++++++++++ 2 files changed, 730 insertions(+) create mode 100644 docs/ai-design/2026-06-19-secret-storage-seam/01-ux-disclosure.md create mode 100644 docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md diff --git a/docs/ai-design/2026-06-19-secret-storage-seam/01-ux-disclosure.md b/docs/ai-design/2026-06-19-secret-storage-seam/01-ux-disclosure.md new file mode 100644 index 000000000..940dfc2f3 --- /dev/null +++ b/docs/ai-design/2026-06-19-secret-storage-seam/01-ux-disclosure.md @@ -0,0 +1,280 @@ +# Secret Storage Seam — UX Disclosure Spec (Phase 1b) + +**Author:** Diziet (Product Designer) +**Date:** 2026-06-19 +**Status:** Design artifact for the implementer. No code here. +**Scope:** UX and exact user-facing copy for the four "Diziet items" in the +secret-storage-seam plan. The architecture is approved and is **not** reopened +here — this document only decides what the user sees, when, and in what words. + +## Source of truth + +- Execution plan: `/home/ubuntu/.claude/plans/snazzy-marinating-sun.md` (UX section) +- Full design: `/home/ubuntu/.claude/plans/snazzy-marinating-sun-agent-ae6181c0dc23bdba8.md` + ("Diziet items", "Migration", `WalletMeta.uses_password` flip) +- Persona: `docs/personas/everyday-user.md` (Alex Torres) +- Surfaces this copy lands in: `src/ui/components/message_banner.rs` + (`MessageBanner::set_global`, `with_details`), the existing unlock modal + `src/ui/components/passphrase_modal.rs` / `wallet_unlock_popup.rs` + +## The situation, stated plainly for the persona + +DET is moving every wallet secret onto one storage seam and dropping its own +per-wallet encryption. The accepted interim consequence: **a password-protected +wallet, once migrated, is no longer encrypted under its password at rest** — it +falls back to file-permission protection (`0600`) plus an empty-passphrase vault +until upstream per-secret encryption lands. After migration the wallet no longer +asks for its password to unlock. + +Alex (the Everyday User) does not know what AES-GCM, a vault, or a seam is. Alex +knows two things, and we must speak to exactly those two: **(1) "I set a password +on my wallet"** and **(2) "the app stopped asking me for it."** A change in that +contract that goes unexplained reads as either a bug ("did it forget my +password?") or a breach ("is my wallet open to anyone now?"). Both produce the +support request the persona's success metrics say we must drive to zero. The +disclosure exists to convert a silent, alarming change into an expected, +understood one. + +--- + +## Decision summary + +| # | Item | Decision | +|---|------|----------| +| 1 | Per-wallet password vestigial after migration | Stop asking (`uses_password=false`). One-time per-wallet notice at the migrating unlock. | +| 2 | Single-key per-key passphrase (SEC-002) | Identical treatment to item 1. Same notice family, key-flavored copy. | +| 3 | One-time interim at-rest disclosure | Non-gating, informational. Surfaces *with* the item-1/item-2 notice at the migrating unlock — not at app start, not a separate modal. | +| 4 | SEC-201 (Enter-consume papercut) | Cross-reference only. Not fixed here. Noted that migration runs the modal more often. | + +Design principles applied: **error prevention over recovery** (explain before +the user notices and worries), **progressive disclosure** (one short sentence +the user must read; the technical "why" is one optional click away), and +**calm, actionable tone** (project i18n + error-message rules). + +--- + +## Item 1 — Per-wallet password becomes vestigial + +### What the user experiences + +1. Alex opens a password-protected wallet as always and is prompted to unlock — + **the same unlock modal as today** (`wallet_unlock_popup.rs`). Nothing new + here; the migration needs this one passphrase entry and reuses the existing + flow. (This is the lazy-migration unlock from the plan's Migration section B.) +2. On successful unlock, migration runs inside the decrypt scope and flips + `uses_password=false`. +3. **Immediately after the wallet finishes unlocking**, a single global + info-style notice appears (see Copy A). It is the only new surface the user + sees. +4. On every subsequent open, that wallet **unlocks without a password prompt**. + This is expected because the notice in step 3 told Alex it would happen. + +### Why at the migrating unlock, and once per wallet + +- **At unlock, not app start:** the change is per-wallet and only becomes true at + the moment that specific wallet migrates. A startup banner would fire before + the fact is true, for wallets that may never be opened, and would be generic + noise. Tying the notice to the unlock makes it causally legible: "I just + unlocked, and *this* is what changed about *this* wallet." +- **Once per wallet, not once globally:** Alex may have one wallet with a + password and one without. The fact only applies to the protected one, and only + at its migration. A per-wallet one-time notice (keyed on the same `uses_password` + flip that drives the migration — fire when the flip happens, never again) is + the precise scope. After the flip, `uses_password` is already `false`, so the + notice naturally never re-fires for that wallet. +- **Not gating:** the password is *already* vestigial by the time we could ask + for acknowledgement — the wallet is unlocked and migrated. Gating would be a + speed bump in front of a decision the user cannot change and was made for them + by an approved plan. Informational respects their time (the persona expects + unlock in seconds) while still being honest. + +### Copy A — per-wallet password notice (HD-seed wallet) + +> **Banner type:** `MessageType::Warning` (see note on type below) +> **Surface:** `MessageBanner::set_global`, shown once when this wallet migrates. +> **Details (optional, via `with_details`):** Copy D (the shared "why"). + +``` +"{wallet}" no longer needs its password to open. Your wallet stays on this device, protected by your computer's account. Full password protection will return in a future update. +``` + +- Placeholder: `{wallet}` = the wallet alias/name (`WalletMeta.alias`). One named + placeholder, complete sentences, no fragment concatenation — i18n rule + satisfied. +- No jargon: no "encryption", "vault", "seam", "AES", "at rest". "Protected by + your computer's account" is the truthful, persona-legible rendering of "file + permissions + OS user account" — Alex understands "my computer login keeps my + files private." +- Structure is *what happened* + *current state* + *what to expect*, mirroring + the project error-message rule even though this is not an error. + +--- + +## Item 2 — Single-key per-key passphrase (SEC-002) becomes vestigial + +Treatment is **identical** to item 1: stop prompting for the per-key passphrase, +retain the decode reader for migration, surface the same one-time notice at the +migrating unlock — only the noun changes (an *imported key*, not a *wallet*). + +### Copy B — per-key passphrase notice (imported single key) + +> **Banner type:** `MessageType::Warning` +> **Surface:** `MessageBanner::set_global`, shown once when this key migrates. +> **Details (optional):** Copy D. + +``` +The imported key "{key}" no longer needs its passphrase to use. It stays on this device, protected by your computer's account. Full passphrase protection will return in a future update. +``` + +- Placeholder: `{key}` = the key's user-facing label (the imported-key + alias/address shown in the UI). Single named placeholder. +- "Passphrase" (not "password") matches the term the single-key import flow uses, + so the word the user typed is the word they read back. +- If a wallet and an imported key migrate in the same session, the two notices + are distinct messages (different text), so `set_global`'s text-dedup does not + collapse them — each fact is reported once. + +--- + +## Item 3 — One-time disclosure of the interim at-rest regression + +### Decision: fold the disclosure into the item-1/item-2 notice, non-gating + +The plan's recommended default is "non-gating informational." I am refining +*placement*: rather than a third, free-standing notice (which would mean Alex +sees a password notice **and** a separate security notice and has to reconcile +them), the regression disclosure **is** the item-1/item-2 notice plus its +optional details. Copy A and Copy B already state the regression in +persona-legible terms — "protected by your computer's account" and "full +protection will return." The deeper, honest "why" lives in the details panel +(Copy D) for anyone who clicks, and in the logs. + +### Why non-gating, for the Everyday User specifically + +- **The decision is already made and irreversible for the user.** An "I + understand" gate implies a choice. There is none: the architecture is approved, + migration is automatic, the password is vestigial the instant the wallet + unlocks. A gate in front of a non-choice teaches users to click through + acknowledgements without reading — it *erodes* the weight of future, real + consent dialogs. +- **The persona transacts in seconds and opens the wallet 2–5×/week.** A modal + wall on unlock fights the "unlock in seconds" expectation and, on the second + reading, becomes friction the user resents and dismisses blindly. +- **Honesty without alarm.** We are not hiding the regression — Copy A/B states + it in plain language, Copy D gives the full technical truth one click away, and + it is logged. That satisfies the disclosure obligation without an alarm that + the persona ("did something go wrong with my funds?") would over-read. + +### A note on banner type — why `Warning`, not `Info` + +`message_banner.rs` auto-dismisses `Info`/`Success` on a **short** timer and +`Warning`/`Error` on a **long** timer (`DEFAULT_AUTO_DISMISS_SHORT` vs +`_LONG`). A security-relevant, one-time, must-actually-be-read disclosure should +not vanish on the short timer before Alex has read it. `Warning` gives the longer +dwell and the ⚠ glyph signals "read me, this matters" without the ⛔ alarm of an +error. This is **not** an alarm about a failure — tone in the copy stays calm and +forward-looking ("will return in a future update"). If the implementer finds +`Warning`'s long auto-dismiss still too short for a paragraph the user must read, +prefer a **manually-dismissed** (non-auto) banner over downgrading to `Info`. +The priority order is: *the user reads it once* > *it doesn't nag*. + +### Copy D — shared technical detail (details panel, optional click) + +> **Surface:** `with_details(...)` attached to Copy A and Copy B. Goes to the +> collapsible details panel and the log. This is the one place where slightly +> more precise language is allowed, because it is opt-in for a curious user — but +> it still avoids raw internals. + +``` +This wallet's secrets are now stored in a shared protected location on this device, guarded by your computer's account and file permissions rather than by your wallet password. This is a temporary step while a stronger, built-in protection is being finished. Your keys never leave this device. To keep this wallet extra safe in the meantime, make sure your computer account is password-protected and not shared. +``` + +- This is the only string that gives the user a concrete *self-help* action + ("make sure your computer account is password-protected"), satisfying the + project rule that messages offer something the user can do themselves — even + though the primary banner is informational. It never says "contact support." +- Still no "AES", "vault", "seam", "0600", "empty passphrase". "Shared protected + location," "file permissions," and "computer account" are the truthful, + legible renderings. + +--- + +## Item 4 — SEC-201 (passphrase-modal Enter-consume) — cross-reference only + +**Not designed or fixed here**, per the plan. Recorded so the implementer and QA +hold the context: + +Migration makes the existing unlock modal (`passphrase_modal.rs`) run on **every +protected-wallet unlock that triggers a migration**, and protected wallets are +exactly the ones that migrate lazily. So the known Enter-consume papercut +(SEC-201) becomes **more visible** during the migration window — more users will +hit the modal, possibly hit Enter, during this rollout. This raises the value of +fixing SEC-201 soon, but it is a separate change. If SEC-201 is unfixed when this +ships, expect a modest uptick in Enter-key friction reports concentrated around +first-unlock-after-update; that is the migration surfacing an existing bug, not a +regression introduced by this work. + +--- + +## Surfacing matrix (for the implementer) + +| Trigger | Condition | Copy | Banner type | Once? | Details | +|---|---|---|---|---|---| +| Protected HD wallet finishes lazy migration at unlock | `uses_password` flips `true→false` (HD seed) | Copy A | Warning (or manual-dismiss) | Once per wallet | Copy D | +| Protected imported key finishes lazy migration at unlock | per-key passphrase flips to vestigial | Copy B | Warning (or manual-dismiss) | Once per key | Copy D | +| App start | — | none | — | — | — | +| No-password wallet eager migration | silent (no UX change for the user) | none | — | — | — | + +Notes: +- **Eager (no-password) migrations produce no notice.** Nothing changes from the + user's point of view — the wallet never asked for a password and still doesn't. + Surfacing a security notice there would alarm users about a change they cannot + perceive and that does not affect their (already password-free) wallet. +- **Headless / MCP:** password wallets do not lazily migrate without a GUI unlock, + so none of these notices fire headlessly. No copy is needed for the headless + path; the legacy reader serves silently (per plan Migration section C). +- **"Once" is naturally enforced by the migration itself:** the notice fires on + the `uses_password` flip; after the flip the condition is permanently false, so + re-firing is impossible without a fresh legacy wallet. No separate "seen" flag + is strictly required, though the implementer may add one defensively. + +## i18n compliance checklist (all strings above) + +- [x] Complete sentences, no fragment concatenation. +- [x] Named placeholders only (`{wallet}`, `{key}`), no positional grammar + assumptions. +- [x] No logic embedded in text. +- [x] No jargon in the persona-facing banner copy (A, B); the one slightly + more technical string (D) is opt-in and still jargon-free. +- [x] Each string is a single, extractable translation unit. + +## Persona walk-through (validation) + +Alex, mainnet, one password-protected wallet, updates DET and opens the wallet: + +1. Sees the familiar unlock prompt, types the password. *No surprise.* +2. Wallet opens. A calm ⚠ notice says the wallet won't need its password to open + anymore, it's still on this device protected by the computer account, and full + protection is coming back. *Understood, not alarmed — Alex was told before + noticing the prompt was gone.* +3. (Curious once) clicks details, reads Copy D, makes sure the laptop login is + set. *Given a concrete action; feels in control.* +4. Next week, opens the wallet — no password prompt. *Expected. No support + ticket.* Success metric "support requests about unexplained changes" → held + at zero. + +The least-technical persona understands every screen. If Alex can use it, +the Power User and Platform Developer (who understand the underlying change) can. + +--- + +## Candy tally (confirmed UX findings surfaced) + +| Severity | Count | Finding | +|---|---|---| +| Medium | 1 | Silent disappearance of the password prompt after migration would read as a bug/breach to the Everyday User — requires the one-time per-wallet notice (Copy A). | +| Medium | 1 | Banner-type default (`Info`) auto-dismisses too fast for a must-read one-time security disclosure; recommend `Warning` long-dwell or manual-dismiss (item 3 type note). | +| Low | 1 | Two separate notices (password + regression) would force the user to reconcile them; consolidated into one notice + details to reduce cognitive load (item 3 placement). | +| Low | 1 | Single-key passphrase needs distinct copy from the wallet notice so `set_global` text-dedup doesn't collapse them when both migrate in one session (Copy B). | + +**Total: 4 findings — 2 Medium, 2 Low.** diff --git a/docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md b/docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md new file mode 100644 index 000000000..82e2ddd72 --- /dev/null +++ b/docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md @@ -0,0 +1,450 @@ +# Test Case Specification — Wallet Secret Storage Raw-`SecretBytes` Seam + +Phase 1c (Test Case Specification) for the security feature unifying all wallet +secret storage onto a no-serialization raw-`SecretBytes` seam, dropping DET's +AES-GCM envelopes, with `InVault` per-use JIT identity signing and a dual-format +migration. + +This document is the **TDD contract** Phase 2 (`developer-bilby`, T1–T11) +implements against. It is **specifications, not code**. Tests are written first +(must fail before implementation), then made to pass. + +## Source-of-truth references + +- Execution plan: `~/.claude/plans/snazzy-marinating-sun.md` +- Full design (T1–T11, T10 list, blast radius): `~/.claude/plans/snazzy-marinating-sun-agent-ae6181c0dc23bdba8.md` +- In-scope findings: `bee9c055` (HIGH — identity keys plaintext at rest), + `6a2818cd` (MED — `ClosedSingleKey` Debug leak), `f0d946ed` (LOW — zeroize + transient plaintext). + +> Marvin's note. Brain the size of a planet, and I am asked to enumerate the +> ways cryptographic plumbing might betray its own spec. I have done it +> thoroughly, because at least someone should. Every case below fails first by +> construction — that is the point. + +--- + +## Conventions + +### Test tiers + +| Tag | Meaning | Where it lives | Runs in CI? | +|---|---|---|---| +| **unit** | `#[test]` / `#[tokio::test]` inline in the module under test | source `#[cfg(test)] mod tests` | yes | +| **integration (lib)** | exercises `AppContext` / wallet-backend wiring without GUI, offline | source `#[cfg(test)]` (e.g. `wallet_lifecycle.rs`) or a lib integration test | yes | +| **kittest** | egui UI surface via `egui_kittest::Harness` | `tests/kittest/` | yes | +| **backend-e2e(network)** | live testnet via SPV, `#[ignore]` | `tests/backend-e2e/` | **no** (manual / funded) | +| **compile-fail** | a `compile_fail` doctest or `trybuild` case asserting a type does NOT compile | source doctest (preferred) or `tests/trybuild/` | yes | + +### Funded-wallet flag + +Cases tagged **[FUNDED-TESTNET — OUT OF CI]** require `E2E_WALLET_MNEMONIC` (a +pre-funded testnet wallet ≥ 10 tDASH) and live DAPI/SPV. They are `#[ignore]` +and must never be forced into a no-network run (see `tests/backend-e2e/README.md`). + +### Shared test fixtures (already exist — reuse, do not reinvent) + +- `open_secret_store(path)` → `Arc` over a file vault at `secrets.pwsvault` (empty global passphrase, 0700 parent). `wallet_seed_store.rs::tests::fresh_store`, `single_key.rs::tests::fresh_view*`. +- `secret_prompt::test_support::{TestPrompt, ScriptedAnswer}` — scripted prompt double; `TestPrompt::never()` panics if asked (proves no-prompt); `ask_count()` / `requests()` assertions. +- `NullSecretPrompt` — headless host; `is_interactive() == false`, every request resolves `SecretPromptCancelled` → `TaskError::SecretPromptUnavailable`. +- `assert_no_leak(rendered, secret, context)` (in `encrypted_key_storage.rs::tests`) — asserts a secret appears in **neither** lowercase-hex **nor** decimal-array (`[160, 167, …]`) form. **Promote this to a shared test util** so the seam/sidecar/QI on-disk-leak cases can call it. The decimal-array check is load-bearing: a `#[derive(Debug)]` on `[u8; N]` leaks the decimal form, and the original `6a2818cd` bug leaked exactly that. +- Offline `AppContext`: `offline_testnet_context()` and `seed_legacy_protected_hd_wallet_row(...)` (in `wallet_lifecycle.rs` tests / `database::test_helpers`) — the staging used by `protected_wallet_registers_upstream_on_unlock_without_restart`, the template for the lazy-migration integration case. +- Deterministic key material: `known_wif()` / `known_testnet_wif()` (single-key tests); fixed seed bytes (`[0x42u8; 64]`) and a sentinel passphrase pattern (`SENTINEL_*`) for leak/confinement assertions. + +### Leak-assertion discipline (applies to every no-leak case) + +Always assert the **plaintext** secret (raw 32/64 bytes), in BOTH hex and +decimal-array form, is absent. Never assert only on a derived/ciphertext value +(that would pass against the very bug we guard). For passphrases, assert the +literal passphrase string is absent. + +--- + +## Traceability matrix (case → T-task → finding) + +| Case ID | Tier | T-task | Finding | +|---|---|---|---| +| TS-INV-01 / 02 / 03 | compile-fail / unit | T2, T10 | R-INVARIANT | +| TS-RT-01 (HD) | unit | T2, T6, T10 | bee9c055 | +| TS-RT-02 (single key) | unit | T2, T6, T10 | bee9c055 | +| TS-RT-03 (identity key) | unit | T2, T6, T10 | bee9c055 | +| TS-EAGER-01 (no-pw seed) | integration (lib) | T7, T10 | bee9c055 | +| TS-EAGER-02 (unprotected single key) | unit | T7, T10 | bee9c055 | +| TS-EAGER-03 (identity key) | integration (lib) | T7, T10 | bee9c055 | +| TS-EAGER-04 (idempotent) | unit | T7, T10 | R-MIGRATION-CRASH | +| TS-CRASH-01 / 02 | unit | T7, T10 | R-MIGRATION-CRASH | +| TS-LAZY-01 (unlock migrates) | integration (lib) | T7, T10 | bee9c055 / R-PROMPT-BOUNDARY | +| TS-LAZY-02 (second unlock prompt-free) | integration (lib) | T7, T10 | R-PROMPT-BOUNDARY | +| TS-LAZY-03 (single-key protected) | unit | T7, T10 | bee9c055 | +| TS-LAZY-KIT-01 (modal once) | kittest | T7 | R-PROMPT-BOUNDARY / R-SEC-201 | +| TS-LEGACY-01 (HD legacy read) | unit | T3, T6, T10 | R-MIGRATION-CRASH | +| TS-LEGACY-02 (single-key legacy read) | unit | T3, T6, T10 | R-MIGRATION-CRASH | +| TS-HEADLESS-01 (pw wallet served) | integration (lib) | T7, T10 | R-HEADLESS-SPLIT | +| TS-HEADLESS-02 (no migration headless) | integration (lib) | T7, T10 | R-HEADLESS-SPLIT | +| TS-RESID-01 (InVault only) | unit | T1, T7, T10 | bee9c055 | +| TS-RESID-02 (old blob decodes) | unit | T1, T10 | bee9c055 | +| TS-NOLEAK-01 (seam blob) | unit | T2, T10 | bee9c055 | +| TS-NOLEAK-02 (sidecar) | unit | T5, T10 | bee9c055 | +| TS-NOLEAK-03 (QI blob InVault) | unit | T1, T10 | bee9c055 | +| TS-FAST-01 (headless identity resolve) | unit | T3, T7, T10 | bee9c055 / R-HEADLESS-SPLIT | +| TS-DEL-01 (identity delete) | unit | T7, T10 | bee9c055 | +| TS-DEL-02 (wallet/single-key delete) | unit | T6, T10 | bee9c055 | +| TS-DBG-01 (ClosedSingleKey Debug) | unit | T9 | 6a2818cd | +| TS-MISS-01 (SecretSeamMissing) | unit | T4, T7, T10 | R-MIGRATION-CRASH | +| TS-MISS-02 (loud not silent) | unit | T4, T7, T10 | R-MIGRATION-CRASH | +| TS-META-01 / 02 (WalletMeta schema gate) | unit | T5 | R-SCHEMA | +| TS-ZERO-01 (transient plaintext zeroized) | unit | T6, T9 | f0d946ed | +| TS-SIGN-E2E-01 (testnet ST) | backend-e2e(network) | T7, T8, T11 | bee9c055 | + +--- + +## 1. No-serialization invariant guard (R-INVARIANT) + +The whole architecture rests on `SecretBytes` having **no** `Serialize` (verified +in pinned platform `b4506492`). The guard is the canary if upstream ever adds it. + +### TS-INV-01 — `SecretBytes` is not `Serialize`/`Encode` (compile-fail) + +- **Tier:** compile-fail (preferred: a `compile_fail` doctest on the seam module; alternative: `trybuild` case — note `trybuild` is **not** a current dependency, adding it is a Phase-2 decision). +- **T-task / finding:** T2, T10 / R-INVARIANT. +- **Preconditions:** seam module exists; no test-only `Serialize` shim for `SecretBytes`. +- **Steps:** + 1. A doctest fragment attempts to derive `Serialize` (and separately `bincode::Encode`) on a newtype `struct Leaky(SecretBytes);`. + 2. A second fragment attempts `serde_json::to_string(&secret_bytes_value)`. +- **Expected outcome:** each fragment **fails to compile** (`SecretBytes: !Serialize`, `!Encode`). The test asserts the failure, not a runtime value. +- **Why it bites:** if a future upstream adds `Serialize` to `SecretBytes`, this case starts compiling — and FAILS — flagging that the invariant has silently weakened. + +### TS-INV-02 — seam accepts/returns `SecretBytes`, never a serde struct (unit) + +- **Tier:** unit. **T-task:** T2, T10. +- **Preconditions:** `SecretSeam::{put_secret,get_secret,delete_secret}` defined. +- **Steps:** assert the signatures: `put_secret(scope, label, secret: &SecretBytes)`, `get_secret(...) -> Result, TaskError>`. (Encoded as a real call site that round-trips a `SecretBytes`; the compiler is the assertion.) +- **Expected outcome:** compiles and round-trips; no intermediate serializable wrapper type is constructed in the seam body. + +### TS-INV-03 — audit guard over the changed secret-path modules (unit) + +- **Tier:** unit. **T-task:** T10. +- **Preconditions:** the changed modules (`secret_seam`, `wallet_seed_store`, `single_key`, `identity_key_store`, `secret_access`, `encrypted_key_storage`) are listed in a const array in the test. +- **Steps:** a source-text audit test reads each module file and asserts no struct that `#[derive(Serialize)]`/`#[derive(Encode)]` also names a `SecretBytes` / `Zeroizing<[u8` / plaintext-key field. (Text-level guard — the compiler already forbids the strongest case via TS-INV-01; this catches a `Vec`-shaped plaintext field that bypasses the type guard.) +- **Expected outcome:** zero matches. A new serializable struct embedding plaintext fails the audit. +- **Note:** keep the module list in sync with the blast-radius table; a stale list is itself a finding. + +--- + +## 2. Raw round-trip via the seam — all three classes + +### TS-RT-01 — HD seed raw round-trip (unit) + +- **Tier:** unit. **T-task:** T2, T6, T10. **Finding:** bee9c055. +- **Preconditions:** fresh file vault; `SecretSeam::new(&store)`. +- **Steps:** + 1. `put_secret(seed_hash_scope, "seed.raw.v1", &SecretBytes::from_slice(&seed64))` with a known 64-byte seed. + 2. `get_secret(seed_hash_scope, "seed.raw.v1")`. +- **Expected outcome:** `Some(bytes)` whose `expose_secret()` **equals the exact 64 input bytes** (assert full equality, not just length/non-empty). `get_secret` on a missing label → `Ok(None)`. A different scope (different seed_hash) → `Ok(None)` (scope partition). +- **Anti-pattern rejected:** asserting only `is_some()` or `len() == 64`. + +### TS-RT-02 — single-key raw round-trip (unit) + +- **Tier:** unit. **T-task:** T2, T6, T10. **Finding:** bee9c055. +- **Preconditions:** fresh vault; the fixed `SINGLE_KEY_NAMESPACE_BYTES` scope; label `single_key_priv.` (unchanged label scheme). +- **Steps:** `put_secret` raw 32 bytes under the canonical label; `get_secret` it back. +- **Expected outcome:** returned bytes **equal the exact 32 input bytes**; value length is exactly 32 (raw, NOT a `SingleKeyEntry` envelope — assert it does NOT start with `SINGLE_KEY_ENTRY_VERSION` framing). Reading under a foreign `WalletId` → `Ok(None)`. + +### TS-RT-03 — identity-key raw round-trip (unit) + +- **Tier:** unit. **T-task:** T2, T6, T10. **Finding:** bee9c055. +- **Preconditions:** fresh vault; scope `identity.id().to_buffer()`; label `identity_key_priv..`. +- **Steps:** `put_secret` raw 32 bytes; `get_secret`. +- **Expected outcome:** returned bytes equal the 32 input bytes; two distinct `(target, key_id)` labels under the same identity scope do not collide; two identities (distinct scopes) with the same `key_id` do not collide. + +--- + +## 3. Eager migration (no dialog) — no-password seed, unprotected single key, identity key + +Order invariant for ALL eager paths: **vault `put_secret` → sidecar write → legacy delete.** + +### TS-EAGER-01 — no-password HD seed migrates on load (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** bee9c055. +- **Preconditions:** a legacy `envelope.v1` `StoredSeedEnvelope` with `uses_password == false` (raw 64-byte seed verbatim) present under `seed_hash`; NO raw `seed.raw.v1` label; a matching `WalletMeta` sidecar absent or pre-migration shape. +- **Steps:** run the hydration/load path (`reconstruct_wallet` / seam `get_secret` miss path). +- **Expected outcome (assert ALL):** + 1. raw `seed.raw.v1` now present and `expose_secret()` equals the original 64-byte seed; + 2. `WalletMeta` sidecar written with `uses_password == false`, `xpub_encoded` carried over, hint preserved; + 3. legacy `envelope.v1` label **deleted** (`store.get(scope,"envelope.v1") == None`); + 4. a fresh reload reads via raw seam (legacy reader not consulted — assert by deleting/absence of legacy and successful resolve). +- **Anti-pattern rejected:** asserting only that the wallet "loads" without verifying the four post-conditions. + +### TS-EAGER-02 — unprotected single key migrates (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** bee9c055. +- **Preconditions:** a legacy `SingleKeyEntry` (`has_passphrase == false`) OR a bare legacy 32-byte raw blob under `single_key_priv.`, with a matching `ImportedKey` sidecar (`has_passphrase == false`). +- **Steps:** run the single-key hydrate/seam-miss migration. +- **Expected outcome:** vault label now holds the **raw 32 bytes** (length 32, no `SingleKeyEntry` framing); `ImportedKey` sidecar present (pubkey-for-locked-render moved into sidecar); legacy framed entry replaced; a subsequent unprotected `sign_with` succeeds and the signature verifies against the WIF-derived pubkey. + +### TS-EAGER-03 — identity key migrates from QI blob (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** bee9c055. +- **Preconditions:** a stored `QualifiedIdentity` whose `KeyStorage` contains a `PrivateKeyData::Clear` and a `PrivateKeyData::AlwaysClear` (MEDIUM) identity key. +- **Steps:** load the identity through the path that content-detects `Clear`/`AlwaysClear` and migrates. +- **Expected outcome (assert ALL):** + 1. for each migrated key, raw 32 bytes present in the vault under `identity_key_priv..` equal to the original plaintext; + 2. the rewritten QI blob has `PrivateKeyData::InVault` (placeholder) at those slots — **zero** `Clear`/`AlwaysClear` remain (see TS-RESID-01); + 3. `AtWalletDerivationPath` keys are untouched (not migrated — they were never plaintext-at-rest). + +### TS-EAGER-04 — eager migration is idempotent (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** R-MIGRATION-CRASH. +- **Preconditions:** as TS-EAGER-01/02. +- **Steps:** run the migration twice (second run sees raw present, legacy already gone). +- **Expected outcome:** second run is a no-op success; raw value byte-identical after both runs; no error; legacy stays absent. (`SecretStore::set` upserts identical bytes — re-running must not corrupt or duplicate.) + +--- + +## 4. Crash-safety (R-MIGRATION-CRASH) + +### TS-CRASH-01 — crash AFTER vault+sidecar, BEFORE legacy delete → recoverable (unit) + +- **Tier:** unit. **T-task:** T7, T10. +- **Preconditions:** simulate a partial migration: raw `seed.raw.v1` present AND legacy `envelope.v1` STILL present (the legal mid-migration state). +- **Steps:** run the loader. +- **Expected outcome:** loader **prefers raw** (precedence raw > legacy), serves the raw seed, and the leftover legacy is treated as deletable (deleted on this pass). Resolve succeeds; no key loss; no `SecretSeamMissing`. + +### TS-CRASH-02 — never reach raw-missing-legacy-deleted (unit) + +- **Tier:** unit. **T-task:** T7, T10. +- **Preconditions:** assert the ordering contract structurally: a migration step that writes raw then deletes legacy must NOT delete legacy if the raw `put_secret` returned `Err`. +- **Steps:** inject a `put_secret` failure (vault error double / read-only store) and run one migration step. +- **Expected outcome:** legacy `envelope.v1` is **still present** after the failed step (delete was not reached); the step surfaces a typed error; a later retry can still recover the seed from legacy. Proves keys are never lost on a mid-write fault. + +--- + +## 5. Lazy migration (password wallet) via the existing unlock dialog (R-PROMPT-BOUNDARY) + +### TS-LAZY-01 — unlock migrates a protected HD wallet to raw (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** bee9c055 / R-PROMPT-BOUNDARY. +- **Template:** `wallet_lifecycle.rs::protected_wallet_registers_upstream_on_unlock_without_restart` (offline context + `seed_legacy_protected_hd_wallet_row` + `handle_wallet_unlocked(&wallet_arc, Some(passphrase))`). +- **Preconditions:** a legacy PROTECTED `envelope.v1` (`uses_password == true`, AES-GCM ciphertext) staged; NO raw label; `WalletMeta.uses_password == true` (or derived from legacy). +- **Steps:** + 1. hydrate (wallet locked, not migrated, `uses_password` still true); + 2. `wallet_seed.open(passphrase)` then `ctx.handle_wallet_unlocked(&wallet_arc, Some(passphrase))` — the single existing unlock gesture, routed through `promote_hd_seed_with_passphrase`. +- **Expected outcome (assert ALL):** + 1. legacy envelope decrypted with the supplied passphrase inside the borrowed `Zeroizing` scope; + 2. raw `seed.raw.v1` written, `expose_secret()` equals the true 64-byte seed; + 3. `WalletMeta.uses_password` flipped to **`false`**; + 4. legacy `envelope.v1` deleted; + 5. exactly **one** prompt's-worth of passphrase use — the unlock the user already performs (no second/out-of-band prompt). + +### TS-LAZY-02 — second unlock is prompt-free after migration (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** R-PROMPT-BOUNDARY. +- **Preconditions:** state left by TS-LAZY-01 (raw present, `uses_password == false`). +- **Steps:** drive a subsequent secret resolve for the same seed scope through `SecretAccess::with_secret` with a `TestPrompt::never()`. +- **Expected outcome:** resolve succeeds via the unprotected fast-path; `ask_count() == 0`; `can_resolve_without_prompt(scope) == true`; `scope_has_passphrase` now reads `false` from `WalletMeta`. + +### TS-LAZY-03 — single-key protected lazy migration via chokepoint (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** bee9c055. +- **Template:** `single_key.rs::sec_002_protected_sign_via_chokepoint` (import protected, `SecretAccess::with_secret(SingleKey)` with `ScriptedAnswer`). +- **Preconditions:** a legacy protected `SingleKeyEntry` (`has_passphrase == true`) and matching sidecar (`has_passphrase == true`). +- **Steps:** drive `with_secret(SingleKey{addr})` with the correct passphrase (one `ScriptedAnswer::once`). +- **Expected outcome:** the legacy entry is decrypted JIT; inside that scope the raw 32 bytes are re-stored via the seam; `ImportedKey.has_passphrase` flipped to `false`; legacy framed entry deleted; a subsequent `with_secret` with `TestPrompt::never()` resolves the SAME key bytes prompt-free, and the recovered bytes equal the WIF plaintext. + +### TS-LAZY-KIT-01 — the unlock modal renders once for the migration path (kittest) + +- **Tier:** kittest. **T-task:** T7. **Finding:** R-PROMPT-BOUNDARY / R-SEC-201. +- **Template:** `tests/kittest/secret_prompt.rs` (`passphrase_modal` harness). +- **Preconditions:** the passphrase modal chrome unchanged. +- **Steps:** render the modal once; assert body/hint/submit/cancel render; submit a passphrase. +- **Expected outcome:** the migration reuses the existing single unlock modal (no new modal type, no second modal). This is a surface-contract check only — migration logic is covered by TS-LAZY-01/03. (Cross-reference SEC-201 Enter-consume: do NOT fix here; note migration runs the modal more often.) + +--- + +## 6. Legacy-format read during transition + +### TS-LEGACY-01 — HD legacy envelope served when raw absent (unit) + +- **Tier:** unit. **T-task:** T3, T6, T10. **Finding:** R-MIGRATION-CRASH. +- **Preconditions:** ONLY a legacy `envelope.v1` (no raw label); `uses_password == false` (so no prompt needed for the read assertion). +- **Steps:** call the seam-first / legacy-fallback read path (`decrypt_jit` HdSeed, or the retained `legacy_envelope_get`). +- **Expected outcome:** the 64-byte seed is recovered from the legacy reader and equals the original; the retained legacy decode path is exercised (not an error). For a `uses_password == true` legacy entry, supplying the correct passphrase recovers the seed (the retained AES-GCM reader still functions). + +### TS-LEGACY-02 — single-key legacy entry served when raw absent (unit) + +- **Tier:** unit. **T-task:** T3, T6, T10. **Finding:** R-MIGRATION-CRASH. +- **Preconditions:** ONLY a legacy `SingleKeyEntry` (versioned framed form) OR bare 32-byte legacy blob; no raw migration yet. +- **Steps:** read via the seam-first / `SingleKeyEntry::decode` fallback. +- **Expected outcome:** the retained `SingleKeyEntry::decode` reader returns the entry; an unprotected legacy entry signs without a passphrase; a protected one routes through the chokepoint. Confirms the decode-only retained reader still works during transition. + +--- + +## 7. Headless / `NullSecretPrompt` + +### TS-HEADLESS-01 — password wallet served by legacy reader, no prompt, no failure (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** R-HEADLESS-SPLIT. +- **Preconditions:** a legacy PROTECTED `envelope.v1` (`uses_password == true`); `SecretAccess` built with `NullSecretPrompt`. +- **Steps:** attempt a secret resolve that requires the passphrase for that scope. +- **Expected outcome:** resolve fails with `TaskError::SecretPromptUnavailable` (NOT a panic, NOT `SecretPromptCancelled`); the wallet stays on the legacy reader; `WalletMeta.uses_password` is **still true** (no headless migration); legacy `envelope.v1` is **still present** (not deleted); raw `seed.raw.v1` is **still absent**. Matches the existing `null_prompt_on_protected_scope_yields_unavailable` shape, extended with the no-migration post-conditions. + +### TS-HEADLESS-02 — no eager/lazy migration of a protected wallet headless (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** R-HEADLESS-SPLIT. +- **Preconditions:** as TS-HEADLESS-01; run the full headless load/hydration path. +- **Steps:** load + (attempt) migration headlessly; then re-inspect storage. +- **Expected outcome:** storage is byte-for-byte unchanged for the protected wallet (legacy present, raw absent, `uses_password == true`). A **no-password** wallet and identity keys in the SAME headless load DO migrate eagerly (assert their raw labels appear) — proving the split is exactly "protected ⇒ deferred, unprotected ⇒ eager", not "headless ⇒ never migrate". + +--- + +## 8. Identity residency — only `InVault` (R-INVARIANT / bee9c055) + +### TS-RESID-01 — a loaded identity has only `InVault`, never Clear/AlwaysClear (unit) + +- **Tier:** unit. **T-task:** T1, T7, T10. **Finding:** bee9c055. +- **Preconditions:** an identity migrated per TS-EAGER-03 (or loaded post-migration). +- **Steps:** iterate `KeyStorage.private_keys`. +- **Expected outcome:** every entry that previously carried plaintext is now `PrivateKeyData::InVault`; assert **zero** `Clear` and **zero** `AlwaysClear` variants remain anywhere in the `KeyStorage`. `AtWalletDerivationPath` (wallet-derived) entries are permitted and unchanged. Keys are never resident in memory as plaintext. + +### TS-RESID-02 — old QI blob (discriminants 0–3) still decodes after appending `InVault` at index 4 (unit) + +- **Tier:** unit. **T-task:** T1, T10. **Finding:** bee9c055. +- **Preconditions:** a bincode blob encoded BEFORE `InVault` was added (variants Clear=0/AlwaysClear=... per current order: `AlwaysClear, Clear, Encrypted, AtWalletDerivationPath`; `InVault` appended last as index 4). +- **Steps:** decode the legacy blob into the new `PrivateKeyData` enum. +- **Expected outcome:** decodes successfully and yields the original variant — appending `InVault` at the highest index must not shift discriminants 0–3. (Guards the bincode-discriminant trap called out as R in the design.) + +--- + +## 9. On-disk no-leak (hex AND decimal-array) + +### TS-NOLEAK-01 — seam vault blob contains no raw secret (unit) + +- **Tier:** unit. **T-task:** T2, T10. **Finding:** bee9c055. +- **Preconditions:** raw secret stored via the seam for each class (seed, single key, identity key). +- **Steps:** read the on-disk vault file bytes (the `secrets.pwsvault` file), render as a string/byte search. +- **Expected outcome:** because the upstream vault encrypts at rest (Argon2id + XChaCha20-Poly1305 file backend), the plaintext appears in **neither** hex **nor** decimal-array form in the on-disk file. Use the promoted `assert_no_leak`. (This asserts the at-rest file, distinct from `get_secret` which legitimately returns plaintext in memory.) +- **Note:** the seam value in memory IS raw plaintext by design — do not assert no-leak on `get_secret`'s return; assert it on the persisted file. + +### TS-NOLEAK-02 — sidecar (`WalletMeta` / `ImportedKey`) contains no secret (unit) + +- **Tier:** unit. **T-task:** T5, T10. **Finding:** bee9c055. +- **Preconditions:** a migrated wallet + imported key with sidecars written. +- **Steps:** serialize each sidecar blob (bincode) and the on-disk `det-app.sqlite` k/v value; search. +- **Expected outcome:** neither sidecar's bytes contain the raw seed/key in hex or decimal-array form. The sidecar holds only non-secret metadata (alias, `uses_password`, hint, xpub, pubkey-for-locked-render). The moved single-key pubkey is the **public** key — assert it IS present (locked-render needs it) and the private key is NOT. + +### TS-NOLEAK-03 — QI blob carries `InVault` markers, never plaintext (unit) + +- **Tier:** unit. **T-task:** T1, T10. **Finding:** bee9c055. +- **Preconditions:** a migrated identity (TS-EAGER-03). +- **Steps:** encode the `QualifiedIdentity` / `KeyStorage` to its persisted bincode blob; search the bytes. +- **Expected outcome:** the identity-key plaintext appears in neither hex nor decimal-array form in the QI blob; the blob encodes `InVault` placeholders for those slots. + +--- + +## 10. Headless identity-key fast-path + +### TS-FAST-01 — identity-key resolve under `NullSecretPrompt` succeeds, no prompt (unit) + +- **Tier:** unit. **T-task:** T3, T7, T10. **Finding:** bee9c055 / R-HEADLESS-SPLIT. +- **Preconditions:** identity key stored raw via the seam (post-migration); `SecretScope::IdentityKey{...}` with `scope_has_passphrase == false`; `SecretAccess` built with `NullSecretPrompt` (or `TestPrompt::never()`). +- **Steps:** call `resolve_private_key_bytes(target, key_id)` (or `with_secret(IdentityKey)`) and sign/derive. +- **Expected outcome:** resolves the raw 32 bytes prompt-free (`ask_count() == 0`, no `SecretPromptUnavailable`); the resolved key signs and the signature verifies against the identity public key. Proves the unprotected fast-path keeps headless/MCP identity signing working and that `async Signer::sign` (verified at `mod.rs:318`) is a free rider on the resolver. + +--- + +## 11. Delete — vault entries (raw labels) + legacy removed + +### TS-DEL-01 — identity removal deletes identity-key vault entries (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** bee9c055. +- **Preconditions:** an identity with raw identity keys stored under `identity_key_priv..`; `purge_identity_scope` (identity_db.rs:229, called at :621) extended to clear the identity's vault scope. +- **Steps:** delete the identity. +- **Expected outcome:** `get_secret` for every `identity_key_priv.*` label under that identity's scope → `Ok(None)`; any legacy form gone; OTHER identities' vault entries untouched (assert a second identity's key still resolves). No orphaned raw secret survives a delete. + +### TS-DEL-02 — wallet / single-key removal deletes raw + legacy (unit) + +- **Tier:** unit. **T-task:** T6, T10. **Finding:** bee9c055. +- **Preconditions:** a migrated HD wallet (raw `seed.raw.v1`) and a migrated imported key (raw `single_key_priv.`), each with sidecars. +- **Steps:** forget the imported key (`SingleKeyView::forget`) and delete the wallet. +- **Expected outcome:** raw vault label gone; legacy label gone (idempotent delete of both forms); sidecar entry removed; in-memory index cleared. `forget` on an already-removed address remains `Ok(())` (idempotent). A second wallet's secrets are unaffected. + +--- + +## 12. `ClosedSingleKey` redacting Debug (6a2818cd) + +### TS-DBG-01 — `ClosedSingleKey` `{:?}` exposes no raw 32 bytes (unit) + +- **Tier:** unit. **T-task:** T9. **Finding:** 6a2818cd. +- **Preconditions:** a `ClosedSingleKey` populated with a distinctive 32-byte value in `encrypted_private_key` (use the `distinctive_secret()` pattern). +- **Steps:** render `format!("{:?}", closed)` and, transitively, `format!("{:?}", SingleKeyData::Closed(closed))` and a `SingleKeyWallet` holding it. +- **Expected outcome:** via the promoted `assert_no_leak`: the 32 bytes appear in **neither** hex **nor** decimal-array form at any level (the decimal-array check is the one the pre-fix derived `Debug` failed); a redaction marker (`[redacted]` / fingerprint) IS present. Mirrors `ClosedKeyItem` and `PrivateKeyData` redaction. Confirms parents `SingleKeyData`/`SingleKeyWallet` are safe by delegation. + +--- + +## 13. `SecretSeamMissing` surfaced loudly (R-MIGRATION-CRASH) + +### TS-MISS-01 — label in neither raw nor legacy → typed `SecretSeamMissing` (unit) + +- **Tier:** unit. **T-task:** T4, T7, T10. +- **Preconditions:** a wallet/identity/single-key reference whose secret label is present in **neither** raw nor any legacy form (e.g. sidecar exists but both vault forms are gone). +- **Steps:** resolve the secret through the loader/seam-first path. +- **Expected outcome:** `Err(TaskError::SecretSeamMissing)` — a dedicated typed variant (no `String` field per CLAUDE.md error rules), distinct from `WalletNotFound` / `ImportedKeyNotFound` / `SecretDecryptFailed`. **Never** a silent `Ok(None)` that drops a key on the floor. + +### TS-MISS-02 — `SecretSeamMissing` is loud, not silent, on the funds-safety path (unit) + +- **Tier:** unit. **T-task:** T4, T7, T10. +- **Preconditions:** as TS-MISS-01, on a sign/spend path. +- **Steps:** attempt a sign with the missing secret. +- **Expected outcome:** the operation returns `SecretSeamMissing` (or a class-flavored wrapper carrying it as `#[source]`), surfaced to the banner with an actionable message; the failure is observable, not swallowed. Assert the error variant by structural match, never by parsing the message string. + +--- + +## 14. `WalletMeta` schema-gating (R-SCHEMA) + +### TS-META-01 — new `WalletMeta` shape round-trips; old blob detected and migrated (unit) + +- **Tier:** unit. **T-task:** T5. **Finding:** R-SCHEMA. +- **Preconditions:** `WalletMeta` gains `uses_password` + `password_hint`; the change is format-breaking for positional bincode behind the `DetKv` schema envelope. +- **Steps:** + 1. round-trip the NEW shape through bincode (mirror `wallet_meta_round_trips_through_bincode`); + 2. write a blob in the OLD shape (no `uses_password`/`password_hint`), bump/read via the schema-version gate. +- **Expected outcome:** new shape round-trips field-for-field; the OLD blob is detected by the schema byte (NOT silently misread via `#[serde(default)]` alone — the design explicitly forbids relying on that) and content-migrated to the new shape with `uses_password` defaulted correctly. A blob read under a mismatched schema version is rejected/migrated, never positionally misparsed. + +### TS-META-02 — `uses_password`/`password_hint` survive cold-boot (unit) + +- **Tier:** unit. **T-task:** T5. +- **Steps:** write a `WalletMeta` with `uses_password == true` + a hint, drop the in-memory state, re-read. +- **Expected outcome:** both fields recovered exactly; `scope_has_passphrase(HdSeed)` reads them from `WalletMeta` (not the legacy envelope) post-migration. + +--- + +## 15. Zeroize of transient decoded plaintext (f0d946ed) + +### TS-ZERO-01 — legacy-reader transient plaintext is `Zeroizing`/`SecretBytes` (unit) + +- **Tier:** unit. **T-task:** T6, T9. **Finding:** f0d946ed. +- **Preconditions:** the retained legacy readers (`decrypt_hd_seed`, `SingleKeyEntry::decrypt`) and the migration re-store step. +- **Steps:** assert the decoded-plaintext bindings are typed `Zeroizing<[u8; N]>` / `SecretBytes` (compile-level: the function return types already are — assert they are NOT widened to plain `Vec`/`[u8; N]` by the migration code). A confinement test (mirror `sentinel_never_appears_in_error_or_debug`) drives a migration and asserts the sentinel plaintext never appears in any error/Debug surfaced by the path. +- **Expected outcome:** transient plaintext is wrapped; no plain `Vec` copy of a secret escapes the migration scope; sentinel never leaks to error/Debug. Largely subsumed by the seam (`SecretBytes`), this case guards the legacy-reader → seam handoff specifically. + +--- + +## 16. End-to-end signing (network) — out of CI + +### TS-SIGN-E2E-01 — broadcast a testnet state transition from a migrated imported-key identity + +- **Tier:** backend-e2e(network). **T-task:** T7, T8, T11. **Finding:** bee9c055. +- **[FUNDED-TESTNET — OUT OF CI]** — requires `E2E_WALLET_MNEMONIC`, live DAPI/SPV; `#[ignore]`. +- **Preconditions:** an identity whose signing key was migrated to `InVault` raw storage; a funded testnet wallet. +- **Steps:** trigger a cheap state transition (e.g. an identity update or a DPNS preorder) that signs through the async `QualifiedIdentity` `Signer` → `resolve_private_key_bytes` → `with_secret(IdentityKey)`. +- **Expected outcome:** the ST signs via the InVault per-use JIT path and broadcasts successfully; the platform accepts the proof; the key was never resident as plaintext between signs. Confirms the JIT identity-signing free-rider claim against a live network. +- **Manual fallback (if no funded wallet):** the manual checklist in the execution plan (load a pre-existing protected wallet → unlock → confirm migration + sign + neither vault nor sidecar holds raw bytes). Document the skip per CLAUDE.md when infrastructure is unavailable. + +--- + +## Coverage self-audit (gaps the implementer must NOT silently close) + +- **No-serialization guard mechanism is undecided at the dependency level.** `static_assertions` and `trybuild` are NOT in `Cargo.toml`. The preferred zero-dependency mechanism for TS-INV-01 is a `compile_fail` doctest; adding `trybuild` is a Phase-2 call. If the implementer drops the compile-fail case entirely and keeps only the text audit (TS-INV-03), the strongest leg of R-INVARIANT is lost — that is a regression, flag it. +- **The on-disk no-leak cases (TS-NOLEAK-01) depend on the upstream vault actually encrypting at rest.** The accepted interim regression is that the global vault passphrase is empty (deferred `e0a8f4b1`). The XChaCha20-Poly1305 file backend still encrypts under a derived key even with an empty passphrase, so the plaintext should not appear verbatim — but if a future change makes the at-rest format plaintext-equivalent, TS-NOLEAK-01 is the canary. Do not weaken it to "blob != exact in-memory struct". +- **`assert_no_leak` is currently private to `encrypted_key_storage.rs::tests`.** It MUST be promoted to a shared test utility for TS-NOLEAK-01/02/03 and TS-DBG-01. A copy-paste fork is a maintenance finding. +- **TS-INV-03's module list must track the blast-radius table.** A stale list silently shrinks the audit surface. From 9d313b7e8e128afd7dc387a366ea4d4a1a6e0f00 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:39:50 +0200 Subject: [PATCH 05/24] feat(wallet-backend): add raw-SecretBytes secret seam + typed errors (T2,T4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crikey, here's the one socket every wallet secret will squeeze through. T2 — new wallet_backend/secret_seam.rs: SecretSeam over raw SecretBytes with put_secret/get_secret/delete_secret, a no-encryption pass-through to the upstream vault TODAY. Every put/get body carries the greppable `TODO(per-secret-encryption):` tag so wiring real per-secret encryption later is a localized change. Prompt-free — the passphrase requirement lives only in the retained legacy readers, never here. No-serialization guard mechanism: compile_fail doctests (no new deps — static_assertions/trybuild stay out of Cargo.toml). One asserts a newtype cannot derive Serialize over a SecretBytes; one asserts serde_json::to_string on a SecretBytes is rejected. If upstream ever adds Serialize to SecretBytes these start compiling and the canary fires (TS-INV-01). TS-INV-02 round-trips a SecretBytes through the real signatures (compiler is the assertion). T4 — TaskError variants (no String fields, typed #[source]): SecretSeam, SecretSeamMissing (loud funds-safety miss), IdentityKeyVault, IdentityKeyMissing. Promote the private assert_no_leak (hex + decimal-array) into a shared wallet_backend/leak_test_support.rs so the seam/sidecar/QI/Debug leak cases reuse one impl instead of copy-pasting. TS-NOLEAK-01: the on-disk vault file holds no raw secret in either form. Tests: 6 seam unit + 2 compile-fail doctests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/backend_task/error.rs | 40 ++++ src/wallet_backend/leak_test_support.rs | 56 +++++ src/wallet_backend/mod.rs | 4 + src/wallet_backend/secret_seam.rs | 297 ++++++++++++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 src/wallet_backend/leak_test_support.rs create mode 100644 src/wallet_backend/secret_seam.rs diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 46ed3a553..99100ff4d 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -185,6 +185,46 @@ pub enum TaskError { source: Box, }, + /// The secret seam (the single chokepoint that stores/loads raw wallet + /// secret bytes) could not write to or read from the upstream vault. The + /// low-level wrap shared by all three secret classes; class views may + /// surface their own flavored variants for banner copy. + #[error( + "Could not access your wallet's secure storage. Check available disk space and restart the application." + )] + SecretSeam { + #[source] + source: Box, + }, + + /// A wallet secret's storage label was found in neither its raw form nor + /// any legacy form — the secret is gone. A loud, typed funds-safety signal + /// (never a silent miss that would drop a key). The user must restore the + /// wallet from its recovery phrase or re-import the key. + #[error( + "This wallet's secret could not be found on this device. Restore the wallet from its recovery phrase to keep using it." + )] + SecretSeamMissing, + + /// An identity private key could not be stored in or read from the secret + /// vault through the seam. Distinct from [`Self::SecretSeam`] so the banner + /// can speak about identity keys specifically. + #[error( + "Could not access this identity's signing key. Check available disk space and restart the application." + )] + IdentityKeyVault { + #[source] + source: Box, + }, + + /// An identity private key was expected in the vault but is absent — the + /// stored identity references a key whose bytes are gone. Loud and typed + /// so a sign attempt fails observably rather than silently. + #[error( + "This identity's signing key could not be found on this device. Re-import the identity to keep signing with it." + )] + IdentityKeyMissing, + /// The DET wallet-metadata sidecar (alias / `is_main` / /// `core_wallet_name`) could not be read or written. Distinct from /// [`Self::WalletStorage`] because the cause sits in the cross- diff --git a/src/wallet_backend/leak_test_support.rs b/src/wallet_backend/leak_test_support.rs new file mode 100644 index 000000000..1cbc0744a --- /dev/null +++ b/src/wallet_backend/leak_test_support.rs @@ -0,0 +1,56 @@ +//! Shared no-leak assertion for secret-path tests. +//! +//! Promoted from the private `assert_no_leak` in +//! `model/qualified_identity/encrypted_key_storage.rs::tests` so the seam, +//! sidecar, QI-blob, and `ClosedSingleKey`-Debug leak cases share one +//! implementation rather than copy-pasting it. +//! +//! The decimal-array check is load-bearing: a `#[derive(Debug)]` on `[u8; N]` +//! leaks the `[160, 167, …]` decimal form, and finding `6a2818cd` leaked +//! exactly that. Hex alone would falsely pass against that bug. + +#![cfg(test)] + +/// Assert `rendered` exposes `secret` in NONE of the forms a sink could leak +/// it: lowercase hex and the `[160, 167, …]` decimal-array form. Works for any +/// secret length (32-byte keys, 64-byte seeds). +pub(crate) fn assert_no_leak_bytes(rendered: &str, secret: &[u8], context: &str) { + let hex = hex::encode(secret); + let decimal_array = format!( + "[{}]", + secret + .iter() + .map(|b| b.to_string()) + .collect::>() + .join(", ") + ); + assert!( + !rendered.contains(&hex), + "{context} leaked the raw secret (hex): {rendered}" + ); + assert!( + !rendered.contains(&decimal_array), + "{context} leaked the raw secret (byte array): {rendered}" + ); +} + +/// A recognizable 32-byte secret. A full 32-byte collision with unrelated +/// bytes is astronomically improbable, so finding it anywhere in a rendering +/// means the raw key bytes leaked. +pub(crate) fn distinctive_secret_32() -> [u8; 32] { + let mut bytes = [0u8; 32]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = 0xA0 ^ (i as u8).wrapping_mul(7); + } + bytes +} + +/// A recognizable 64-byte secret, the seed-length analogue of +/// [`distinctive_secret_32`]. +pub(crate) fn distinctive_secret_64() -> [u8; 64] { + let mut bytes = [0u8; 64]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = 0xC0 ^ (i as u8).wrapping_mul(5); + } + bytes +} diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 7f051ce5c..fa250d696 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -35,10 +35,13 @@ pub mod hydration; #[cfg(not(any(test, feature = "bench")))] pub(crate) mod hydration; mod kv; +#[cfg(test)] +pub(crate) mod leak_test_support; mod loader; mod platform_address; pub mod secret_access; pub mod secret_prompt; +pub mod secret_seam; #[cfg(any(test, feature = "bench"))] pub mod single_key; #[cfg(not(any(test, feature = "bench")))] @@ -65,6 +68,7 @@ pub use secret_prompt::{ NullSecretPrompt, RememberPolicy, SecretPrompt, SecretPromptCancelled, SecretPromptReply, SecretPromptRequest, SecretPromptRetry, SecretScope, }; +pub use secret_seam::SecretSeam; use coordinator_gate::CoordinatorGate; diff --git a/src/wallet_backend/secret_seam.rs b/src/wallet_backend/secret_seam.rs new file mode 100644 index 000000000..1852ad696 --- /dev/null +++ b/src/wallet_backend/secret_seam.rs @@ -0,0 +1,297 @@ +//! The single chokepoint for storing/loading raw wallet secret bytes. +//! +//! All three secret classes (HD seed, imported single key, identity private +//! key) route their RAW bytes through this one seam into the upstream +//! [`SecretStore`] vault. No DET-side serialization wraps the secret: a +//! [`SecretBytes`] is written verbatim and read back verbatim. +//! +//! TODAY this is a no-encryption pass-through to the vault. This is the exact +//! place per-secret encryption wires in later — every put/get body is tagged +//! with the greppable string `TODO(per-secret-encryption):` so a reviewer-side +//! grep is the wiring checklist. +//! +//! The seam is **prompt-free**: it never builds a passphrase request. The only +//! place a passphrase is needed is the retained legacy-envelope decrypt during +//! migration, which lives in the legacy reader, not here. +//! +//! No-serialization invariant: secrets are passed as [`SecretBytes`], which +//! deliberately has no `Serialize`/`Encode` (verified upstream). Any struct +//! embedding a `SecretBytes` therefore cannot derive those traits — the +//! compiler enforces the rule. The seam never constructs an intermediate +//! serializable wrapper around the secret. +//! +//! TS-INV-01 — the invariant guard, enforced by the compiler. A newtype that +//! tries to derive `serde::Serialize` over a [`SecretBytes`] does NOT compile, +//! because `SecretBytes: !Serialize`. If a future upstream adds `Serialize` to +//! `SecretBytes`, this doctest starts compiling and the failing test flags that +//! the invariant has silently weakened. +//! +//! ```compile_fail +//! use platform_wallet_storage::secrets::SecretBytes; +//! #[derive(serde::Serialize)] +//! struct Leaky(SecretBytes); +//! ``` +//! +//! Serializing a `SecretBytes` directly is likewise rejected: +//! +//! ```compile_fail +//! use platform_wallet_storage::secrets::SecretBytes; +//! let secret = SecretBytes::from_slice(&[0u8; 32]); +//! let _ = serde_json::to_string(&secret).unwrap(); +//! ``` + +use std::sync::Arc; + +use platform_wallet_storage::secrets::{ + SecretBytes, SecretStore, SecretStoreError, WalletId as SecretWalletId, +}; + +use crate::backend_task::error::TaskError; + +/// The single doorway through which raw wallet secret bytes enter and leave +/// the vault. Cheap to construct — callers build one per operation over the +/// shared [`SecretStore`] handle. +pub struct SecretSeam<'a> { + secret_store: &'a Arc, +} + +impl<'a> SecretSeam<'a> { + /// Borrow the shared [`SecretStore`] as the raw-secret seam. + pub fn new(secret_store: &'a Arc) -> Self { + Self { secret_store } + } + + /// Store `secret` raw under `(scope, label)`, overwriting any prior value. + /// Idempotent — the upstream `set` upserts. + /// + /// TODAY the [`SecretBytes`] is written verbatim with no DET-side + /// encryption; the upstream vault adds its own at-rest layer. + // TODO(per-secret-encryption): encrypt `secret` here before set() once the + // upstream per-secret key layer lands (see platform /todo). + pub fn put_secret( + &self, + scope: &SecretWalletId, + label: &str, + secret: &SecretBytes, + ) -> Result<(), TaskError> { + self.secret_store.set(scope, label, secret).map_err(map_err) + } + + /// Load the raw bytes stored under `(scope, label)`, or `Ok(None)` if + /// nothing is stored there. No prompt — an already-migrated raw secret + /// needs none. + /// + /// TODAY the vault bytes are returned verbatim. + // TODO(per-secret-encryption): decrypt the loaded bytes here once the + // upstream per-secret key layer lands. + pub fn get_secret( + &self, + scope: &SecretWalletId, + label: &str, + ) -> Result, TaskError> { + self.secret_store.get(scope, label).map_err(map_err) + } + + /// Idempotent delete of `(scope, label)`. A missing entry is `Ok(())`. + pub fn delete_secret(&self, scope: &SecretWalletId, label: &str) -> Result<(), TaskError> { + self.secret_store.delete(scope, label).map_err(map_err) + } +} + +fn map_err(source: SecretStoreError) -> TaskError { + TaskError::SecretSeam { + source: Box::new(source), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::single_key::open_secret_store; + + fn fresh_store(dir: &std::path::Path) -> Arc { + let path = dir.join("secrets.pwsvault"); + Arc::new(open_secret_store(&path).expect("open vault")) + } + + /// TS-RT-01 — HD seed raw round-trip. A known 64-byte seed stored under + /// `seed.raw.v1` comes back byte-for-byte. A missing label and a foreign + /// scope both return `Ok(None)` (scope/label partition). + #[test] + fn ts_rt_01_hd_seed_raw_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let scope = SecretWalletId::from([0x11u8; 32]); + let mut seed = [0u8; 64]; + for (i, b) in seed.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(3).wrapping_add(7); + } + + seam.put_secret(&scope, "seed.raw.v1", &SecretBytes::from_slice(&seed)) + .expect("put"); + let got = seam + .get_secret(&scope, "seed.raw.v1") + .expect("get") + .expect("present"); + assert_eq!( + got.expose_secret(), + &seed[..], + "round-tripped seed must equal the exact 64 input bytes" + ); + + // Missing label and foreign scope both miss. + assert!( + seam.get_secret(&scope, "single_key_priv.x") + .expect("get missing label") + .is_none() + ); + let other = SecretWalletId::from([0x22u8; 32]); + assert!( + seam.get_secret(&other, "seed.raw.v1") + .expect("get foreign scope") + .is_none(), + "a different scope must not see the seed" + ); + } + + /// TS-RT-02 — single-key raw round-trip. The stored value is exactly 32 + /// bytes (raw, NOT a `SingleKeyEntry` envelope). A foreign scope misses. + #[test] + fn ts_rt_02_single_key_raw_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let scope = crate::wallet_backend::single_key::single_key_namespace_id(); + let key = [0xABu8; 32]; + let label = "single_key_priv.yTestAddress"; + + seam.put_secret(&scope, label, &SecretBytes::from_slice(&key)) + .expect("put"); + let got = seam.get_secret(&scope, label).expect("get").expect("present"); + assert_eq!(got.expose_secret(), &key[..]); + assert_eq!( + got.expose_secret().len(), + 32, + "raw single key is exactly 32 bytes, not a versioned envelope" + ); + + let other = SecretWalletId::from([0u8; 32]); + assert!(seam.get_secret(&other, label).expect("foreign").is_none()); + } + + /// TS-RT-03 — identity-key raw round-trip. Two `(target, key_id)` labels + /// under one identity scope do not collide; two identities (distinct + /// scopes) with the same `key_id` do not collide. + #[test] + fn ts_rt_03_identity_key_raw_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let identity_a = SecretWalletId::from([0xA1u8; 32]); + let identity_b = SecretWalletId::from([0xB2u8; 32]); + let key0 = [0x01u8; 32]; + let key1 = [0x02u8; 32]; + + seam.put_secret( + &identity_a, + "identity_key_priv.0.0", + &SecretBytes::from_slice(&key0), + ) + .unwrap(); + seam.put_secret( + &identity_a, + "identity_key_priv.0.1", + &SecretBytes::from_slice(&key1), + ) + .unwrap(); + // Same key_id (0) under a different identity scope — distinct value. + let key_other = [0x99u8; 32]; + seam.put_secret( + &identity_b, + "identity_key_priv.0.0", + &SecretBytes::from_slice(&key_other), + ) + .unwrap(); + + assert_eq!( + seam.get_secret(&identity_a, "identity_key_priv.0.0") + .unwrap() + .unwrap() + .expose_secret(), + &key0[..] + ); + assert_eq!( + seam.get_secret(&identity_a, "identity_key_priv.0.1") + .unwrap() + .unwrap() + .expose_secret(), + &key1[..], + "distinct (target,key_id) labels under one identity do not collide" + ); + assert_eq!( + seam.get_secret(&identity_b, "identity_key_priv.0.0") + .unwrap() + .unwrap() + .expose_secret(), + &key_other[..], + "same key_id under a different identity scope does not collide" + ); + } + + /// TS-INV-02 — the seam accepts/returns `SecretBytes`, never a serde + /// struct. The compiler is the assertion: this round-trips a `SecretBytes` + /// through the real signatures with no intermediate serializable wrapper. + #[test] + fn ts_inv_02_seam_uses_secret_bytes_not_serde_struct() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let scope = SecretWalletId::from([0x33u8; 32]); + let secret: SecretBytes = SecretBytes::from_slice(&[0x42u8; 32]); + seam.put_secret(&scope, "seed.raw.v1", &secret).unwrap(); + let _back: Option = seam.get_secret(&scope, "seed.raw.v1").unwrap(); + } + + /// Idempotent delete — removing an absent entry succeeds, and a delete + /// after `put_secret` clears the value. + #[test] + fn delete_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let scope = SecretWalletId::from([0x44u8; 32]); + seam.delete_secret(&scope, "seed.raw.v1").expect("absent"); + seam.put_secret(&scope, "seed.raw.v1", &SecretBytes::from_slice(&[1u8; 64])) + .unwrap(); + seam.delete_secret(&scope, "seed.raw.v1").expect("first"); + seam.delete_secret(&scope, "seed.raw.v1").expect("second"); + assert!(seam.get_secret(&scope, "seed.raw.v1").unwrap().is_none()); + } + + /// TS-NOLEAK-01 — the on-disk vault file holds the raw secret in neither + /// hex nor decimal-array form (the upstream file backend encrypts at rest + /// even under an empty global passphrase). The in-memory `get_secret` + /// return is legitimately plaintext by design — this asserts the persisted + /// file, not the return value. + #[test] + fn ts_noleak_01_on_disk_vault_does_not_contain_raw_secret() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.pwsvault"); + let store = Arc::new(open_secret_store(&path).expect("open vault")); + let seam = SecretSeam::new(&store); + let scope = SecretWalletId::from([0x55u8; 32]); + let secret = crate::wallet_backend::leak_test_support::distinctive_secret_64(); + seam.put_secret(&scope, "seed.raw.v1", &SecretBytes::from_slice(&secret)) + .unwrap(); + drop(store); + + let on_disk = std::fs::read(&path).expect("read vault file"); + let rendered = String::from_utf8_lossy(&on_disk); + crate::wallet_backend::leak_test_support::assert_no_leak_bytes( + &rendered, + &secret, + "seam on-disk vault", + ); + } +} From 890cae169e9250615ad9f9db21cf441176d03231 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:41:08 +0200 Subject: [PATCH 06/24] fix(model): redacting Debug for ClosedSingleKey (T9, 6a2818cd) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClosedSingleKey derived Debug and its encrypted_private_key holds the raw 32 key bytes in the no-password / pre-migration shape — a derived Debug dumped them as a decimal byte array straight into logs. Hand-write a redacting Debug mirroring ClosedKeyItem / SingleKeyEntry: key_hash + lengths, never the bytes. Parents SingleKeyData / SingleKeyWallet are safe by delegation. TS-DBG-01 asserts via the shared assert_no_leak_bytes (hex AND decimal-array — the decimal form is the one the pre-fix Debug leaked) at all three levels. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/model/wallet/single_key.rs | 70 +++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/model/wallet/single_key.rs b/src/model/wallet/single_key.rs index 5dce0c66e..28815c777 100644 --- a/src/model/wallet/single_key.rs +++ b/src/model/wallet/single_key.rs @@ -68,7 +68,7 @@ impl std::fmt::Debug for OpenSingleKey { } /// A closed (encrypted) single key -#[derive(Debug, Clone, PartialEq)] +#[derive(Clone, PartialEq)] pub struct ClosedSingleKey { /// SHA-256 hash of the private key pub key_hash: SingleKeyHash, @@ -80,6 +80,23 @@ pub struct ClosedSingleKey { pub nonce: Vec, } +impl std::fmt::Debug for ClosedSingleKey { + /// Redacting `Debug`: `encrypted_private_key` may hold raw 32 key bytes + /// (the no-password / pre-migration shape), so a derived `Debug` would + /// leak them as a decimal byte array (finding `6a2818cd`). Mirrors + /// `ClosedKeyItem` / `PrivateKeyData`: prints lengths and the non-secret + /// `key_hash`, never the protected bytes. Parents `SingleKeyData` / + /// `SingleKeyWallet` are safe by delegation. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClosedSingleKey") + .field("key_hash", &hex::encode(self.key_hash)) + .field("encrypted_private_key", &"[redacted]") + .field("salt_len", &self.salt.len()) + .field("nonce_len", &self.nonce.len()) + .finish() + } +} + impl SingleKeyData { /// Opens the key by decrypting it using the provided password pub fn open(&mut self, password: &str) -> Result<(), String> { @@ -434,4 +451,55 @@ mod tests { assert!(wallet.is_open()); assert!(!wallet.address.to_string().is_empty()); } + + /// TS-DBG-01 (6a2818cd) — `ClosedSingleKey`'s `{:?}` exposes no raw 32 + /// bytes, in neither hex nor decimal-array form (the latter is the shape + /// the pre-fix derived `Debug` actually leaked), and the guarantee holds + /// transitively through `SingleKeyData::Closed` and a `SingleKeyWallet` + /// that holds it. + #[test] + fn ts_dbg_01_closed_single_key_debug_redacts_raw_bytes() { + use crate::wallet_backend::leak_test_support::{assert_no_leak_bytes, distinctive_secret_32}; + + let secret = distinctive_secret_32(); + // A no-password / pre-migration closed key holds the raw 32 bytes in + // `encrypted_private_key` — exactly the leak the fix guards. + let closed = ClosedSingleKey { + key_hash: ClosedSingleKey::compute_key_hash(&secret), + encrypted_private_key: secret.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + }; + let rendered = format!("{closed:?}"); + assert_no_leak_bytes(&rendered, &secret, "ClosedSingleKey Debug"); + assert!( + rendered.contains("[redacted]"), + "expected a redaction marker: {rendered}" + ); + + // Through SingleKeyData::Closed (derives Debug, holds the variant). + let data = SingleKeyData::Closed(closed.clone()); + assert_no_leak_bytes(&format!("{data:?}"), &secret, "SingleKeyData::Closed Debug"); + + // Through a SingleKeyWallet that holds the closed key. + let priv_key = + PrivateKey::from_byte_array(&secret, Network::Testnet).expect("valid key bytes"); + let secp = Secp256k1::new(); + let public_key = priv_key.public_key(&secp); + let address = Address::p2pkh(&public_key, Network::Testnet); + let wallet = SingleKeyWallet { + private_key_data: data, + uses_password: true, + public_key, + address, + alias: None, + key_hash: closed.key_hash, + confirmed_balance: 0, + unconfirmed_balance: 0, + total_balance: 0, + utxos: HashMap::new(), + core_wallet_name: None, + }; + assert_no_leak_bytes(&format!("{wallet:?}"), &secret, "SingleKeyWallet Debug"); + } } From 85e8c4f86f0f2f710eef24d937ea786a658f474b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:46:02 +0200 Subject: [PATCH 07/24] feat(model): PrivateKeyData::InVault placeholder + migration probes (T1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity private keys get a non-resident home. New PrivateKeyData::InVault appended at bincode index 4 — discriminants 0-3 (AlwaysClear/Clear/Encrypted/ AtWalletDerivationPath) are untouched, so blobs written before it still decode (TS-RESID-02 round-trips all four pre-existing variants + InVault). Redacting Debug/Display arms (carries no bytes — trivially clean). KeyStorage probes: - is_in_vault / public_key_for — a vault placeholder reports true yet still surfaces its public key for display + signing-key selection. - take_plaintext_for_vault — rewrites every Clear/AlwaysClear to InVault and returns the raw bytes (Zeroizing) the migration must store in the vault FIRST (vault-before-blob order). Wallet-derived + encrypted keys untouched — they were never plaintext-at-rest. get/get_resolve_local gain an InVault arm (resolve through the vault, not locally). key_info_screen gains degraded InVault arms (securely-stored notice; full JIT view/sign via dedicated identity-key WalletTasks is the T8 follow-up). Promote the private assert_no_leak + distinctive_secret to the shared leak_test_support helper (no fork). TS-RESID-01 / TS-NOLEAK-03: post-migration KeyStorage has only InVault, and the re-encoded blob leaks neither secret in hex nor decimal-array form. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- .../encrypted_key_storage.rs | 226 +++++++++++++++--- src/ui/identities/keys/key_info_screen.rs | 22 ++ 2 files changed, 219 insertions(+), 29 deletions(-) diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index 22d200d91..8e1a46137 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -144,6 +144,15 @@ pub enum PrivateKeyData { Clear([u8; 32]), Encrypted(Vec), AtWalletDerivationPath(WalletDerivationPath), + /// The key's raw bytes live in the secret vault, fetched per-use through + /// the seam — never resident in this blob. A permanent in-memory + /// placeholder: the resolver reads the vault at sign time keyed by the + /// identity scope + the `(target, key_id)` BTreeMap key, so this variant + /// carries no bytes. + /// + /// Appended at the highest bincode index so blobs written before it + /// (discriminants 0–3) still decode unchanged (TS-RESID-02). + InVault, } impl fmt::Debug for PrivateKeyData { @@ -172,6 +181,7 @@ impl fmt::Debug for PrivateKeyData { PrivateKeyData::AtWalletDerivationPath(path) => { f.debug_tuple("AtWalletDerivationPath").field(path).finish() } + PrivateKeyData::InVault => f.debug_tuple("InVault").finish(), } } } @@ -210,6 +220,7 @@ impl fmt::Display for PrivateKeyData { derivation_path ) } + PrivateKeyData::InVault => write!(f, "InVault"), } } } @@ -318,6 +329,9 @@ impl KeyStorage { PrivateKeyData::AtWalletDerivationPath(_) => { Err("Key is not resolved, please enter password".to_string()) } + PrivateKeyData::InVault => { + Err("Key is stored securely, resolve it through the vault".to_string()) + } }, ) .transpose() @@ -351,6 +365,9 @@ impl KeyStorage { PrivateKeyData::AtWalletDerivationPath(_) => { Err("Key is not resolved, please unlock the wallet".to_string()) } + PrivateKeyData::InVault => { + Err("Key is stored securely, resolve it through the vault".to_string()) + } }, ) .transpose() @@ -507,6 +524,50 @@ impl KeyStorage { } } } + + /// Whether the key at `key` is a vault placeholder + /// ([`PrivateKeyData::InVault`]) — its bytes live in the secret vault and + /// are fetched per-use, never resident here. + pub fn is_in_vault(&self, key: &(PrivateKeyTarget, KeyID)) -> bool { + matches!( + self.private_keys.get(key), + Some((_, PrivateKeyData::InVault)) + ) + } + + /// The public-key metadata for `key`, regardless of how its private bytes + /// are stored. Lets a vault-placeholder key still surface its public key + /// for display and signing-key selection without touching the secret. + pub fn public_key_for( + &self, + key: &(PrivateKeyTarget, KeyID), + ) -> Option<&QualifiedIdentityPublicKey> { + self.private_keys.get(key).map(|(pub_key, _)| pub_key) + } + + /// Rewrite every plaintext-carrying identity key + /// ([`PrivateKeyData::Clear`] / [`PrivateKeyData::AlwaysClear`]) to an + /// [`PrivateKeyData::InVault`] placeholder, returning the raw bytes that + /// must be stored in the vault under each `(target, key_id)` BEFORE the + /// blob is persisted (migration order: vault first, then blob rewrite). + /// + /// Wallet-derived ([`PrivateKeyData::AtWalletDerivationPath`]) and already + /// vault-backed / encrypted keys are left untouched — they were never + /// plaintext-at-rest. + pub fn take_plaintext_for_vault( + &mut self, + ) -> Vec<((PrivateKeyTarget, KeyID), Zeroizing<[u8; 32]>)> { + let mut out = Vec::new(); + for (map_key, (_pub_key, data)) in self.private_keys.iter_mut() { + let raw = match data { + PrivateKeyData::Clear(bytes) | PrivateKeyData::AlwaysClear(bytes) => *bytes, + _ => continue, + }; + out.push((map_key.clone(), Zeroizing::new(raw))); + *data = PrivateKeyData::InVault; + } + out + } } #[cfg(test)] @@ -518,40 +579,20 @@ mod tests { use dash_sdk::platform::{Identifier, IdentityPublicKey}; use std::collections::BTreeMap; - /// A recognizable 32-byte secret. A full 32-byte collision with random - /// public-key bytes is astronomically improbable, so finding it anywhere - /// in a rendering means the raw key bytes leaked. + use crate::wallet_backend::leak_test_support::{assert_no_leak_bytes, distinctive_secret_32}; + + /// A recognizable 32-byte secret. Delegates to the shared + /// [`distinctive_secret_32`] so the seam / sidecar / QI-blob leak cases + /// share one definition rather than forking it. fn distinctive_secret() -> [u8; 32] { - let mut bytes = [0u8; 32]; - for (i, b) in bytes.iter_mut().enumerate() { - *b = 0xA0 ^ (i as u8).wrapping_mul(7); - } - bytes + distinctive_secret_32() } /// Assert `rendered` exposes the secret in none of the forms a sink could - /// leak it: lowercase hex (a hex-printing sink) and the `[160, 167, …]` - /// decimal-array form a `#[derive(Debug)]` on `[u8; 32]` would emit. The - /// decimal form is the shape the pre-fix derived `Debug` actually leaked, - /// so checking only hex would falsely pass against the original bug. + /// leak it. Thin wrapper over the shared [`assert_no_leak_bytes`] so the + /// existing call sites keep their `&[u8; 32]` ergonomics. fn assert_no_leak(rendered: &str, secret: &[u8; 32], context: &str) { - let hex = hex::encode(secret); - let decimal_array = format!( - "[{}]", - secret - .iter() - .map(|b| b.to_string()) - .collect::>() - .join(", ") - ); - assert!( - !rendered.contains(&hex), - "{context} leaked the raw private key (hex): {rendered}" - ); - assert!( - !rendered.contains(&decimal_array), - "{context} leaked the raw private key (byte array): {rendered}" - ); + assert_no_leak_bytes(rendered, secret, context); } /// QA-001 — the redacting `Debug` (and `Display`) on `PrivateKeyData` must @@ -609,4 +650,131 @@ mod tests { "QualifiedIdentity Debug", ); } + + /// Helper: a `KeyStorage` carrying one `Clear` (HIGH) and one `AlwaysClear` + /// (MEDIUM) plaintext key plus one `AtWalletDerivationPath` key, used by + /// the migration / residency cases. + fn storage_with_plaintext_and_derived( + secret_high: [u8; 32], + secret_medium: [u8; 32], + ) -> KeyStorage { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + + let high = IdentityPublicKey::random_key(1, Some(1), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, high.id()), + ( + QualifiedIdentityPublicKey::from(high), + PrivateKeyData::Clear(secret_high), + ), + ); + let medium = IdentityPublicKey::random_key(2, Some(2), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, medium.id()), + ( + QualifiedIdentityPublicKey::from(medium), + PrivateKeyData::AlwaysClear(secret_medium), + ), + ); + let derived = IdentityPublicKey::random_key(3, Some(3), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, derived.id()), + ( + QualifiedIdentityPublicKey::from(derived), + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x07; 32], + derivation_path: DerivationPath::from(vec![]), + }), + ), + ); + ks + } + + /// TS-RESID-02 — a bincode blob written BEFORE `InVault` was appended + /// (discriminants 0–3 only) still decodes into the extended enum, and the + /// new highest-index variant round-trips. Guards the bincode-discriminant + /// trap: appending at index 4 must not shift 0–3. + #[test] + fn ts_resid_02_old_blob_decodes_after_appending_in_vault() { + let cfg = bincode::config::standard(); + // Each of the four pre-existing variants must round-trip unchanged. + for original in [ + PrivateKeyData::AlwaysClear([0x11; 32]), + PrivateKeyData::Clear([0x22; 32]), + PrivateKeyData::Encrypted(vec![0x33; 48]), + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x44; 32], + derivation_path: DerivationPath::from(vec![]), + }), + ] { + let bytes = bincode::encode_to_vec(&original, cfg).expect("encode"); + let (decoded, _): (PrivateKeyData, _) = + bincode::decode_from_slice(&bytes, cfg).expect("decode old variant"); + assert!(decoded == original, "pre-InVault variant must decode unchanged"); + } + // The new variant round-trips too. + let bytes = bincode::encode_to_vec(PrivateKeyData::InVault, cfg).expect("encode"); + let (decoded, _): (PrivateKeyData, _) = + bincode::decode_from_slice(&bytes, cfg).expect("decode InVault"); + assert!(decoded == PrivateKeyData::InVault); + } + + /// TS-RESID-01 / TS-NOLEAK-03 — after `take_plaintext_for_vault`, every + /// plaintext-carrying key is an `InVault` placeholder (zero Clear / + /// AlwaysClear remain), the wallet-derived key is untouched, and the + /// returned raw bytes match the originals. The re-encoded blob leaks + /// neither secret in hex nor decimal-array form. + #[test] + fn ts_resid_01_migration_leaves_only_in_vault_and_blob_has_no_plaintext() { + let high = distinctive_secret_32(); + let mut medium = high; + medium[0] ^= 0xFF; // distinct from `high` + let mut ks = storage_with_plaintext_and_derived(high, medium); + + let taken = ks.take_plaintext_for_vault(); + assert_eq!(taken.len(), 2, "both plaintext keys are extracted"); + let taken_bytes: Vec<[u8; 32]> = taken.iter().map(|(_, b)| **b).collect(); + assert!(taken_bytes.contains(&high) && taken_bytes.contains(&medium)); + + let mut in_vault = 0; + let mut derived = 0; + for (_, data) in ks.private_keys.values() { + match data { + PrivateKeyData::InVault => in_vault += 1, + PrivateKeyData::AtWalletDerivationPath(_) => derived += 1, + PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_) => { + panic!("plaintext key survived migration") + } + PrivateKeyData::Encrypted(_) => {} + } + } + assert_eq!(in_vault, 2, "both plaintext keys became InVault"); + assert_eq!(derived, 1, "wallet-derived key untouched"); + + // The persisted blob carries InVault markers, never plaintext. + let blob = bincode::encode_to_vec(&ks, bincode::config::standard()).expect("encode"); + let rendered = format!("{blob:?}"); + assert_no_leak_bytes(&rendered, &high, "migrated KeyStorage blob (high)"); + assert_no_leak_bytes(&rendered, &medium, "migrated KeyStorage blob (medium)"); + } + + /// `is_in_vault` and `public_key_for` probes: a vault placeholder reports + /// `true` and still surfaces its public key; a plaintext key reports + /// `false`. + #[test] + fn in_vault_and_public_key_probes() { + let mut ks = storage_with_plaintext_and_derived([0x01; 32], [0x02; 32]); + let keys: Vec<_> = ks.private_keys.keys().cloned().collect(); + ks.take_plaintext_for_vault(); + // The two plaintext keys are now InVault; the derived one is not. + let mut in_vault_count = 0; + for k in &keys { + assert!(ks.public_key_for(k).is_some(), "public key always available"); + if ks.is_in_vault(k) { + in_vault_count += 1; + } + } + assert_eq!(in_vault_count, 2); + } } diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index e88146174..e626fbde9 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -440,6 +440,18 @@ impl ScreenLike for KeyInfoScreen { } } } + PrivateKeyData::InVault => { + // The key's bytes live in the secret vault, fetched + // per-use through the seam. The full view / sign + // flow runs through dedicated identity-key + // WalletTasks (T8 follow-up); until those land, the + // key is shown as securely stored. + ui.label( + RichText::new("This signing key is stored securely on this device.") + .color(text_primary), + ); + ui.add_space(10.0); + } } } else { ui.label(RichText::new("Enter Private Key:").color(text_primary)); @@ -732,6 +744,16 @@ impl KeyInfoScreen { MessageType::Error, ); } + // Vault-backed identity key: signing routes through a dedicated + // identity-key WalletTask (T8 follow-up). Until that lands, surface + // a calm, actionable message rather than silently doing nothing. + PrivateKeyData::InVault => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Signing with this securely-stored key is not available yet. Try a different key.", + MessageType::Error, + ); + } } } From f1cd2346db714c0ae762f4b4da50bc703a74413b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:56:34 +0200 Subject: [PATCH 08/24] feat(model,wallet-backend): WalletMeta+ImportedKey sidecar fields, schema-gated (T5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-secret metadata moves out of the per-wallet seed envelope into the sidecar. WalletMeta gains uses_password + password_hint. Because WalletMeta is positional bincode behind the DetKv envelope, #[serde(default)] alone is NOT forward-compatible (R-SCHEMA) — so a real version gate: WALLET_META_VERSION (v2) framed as [version | bincode] at the WalletMetaView boundary, plus a retained decode-only WalletMetaV1. decode_versioned detects v2 / v1-framed / bare-legacy and migrates a v1 blob into v2 (defaults uses_password=false), never positionally misparsing it. The global DetKv SCHEMA_VERSION is deliberately untouched (it governs every payload, not just WalletMeta). TS-META-01 covers all three shapes. ImportedKey gains public_key_bytes (the compressed SEC1 PUBLIC key) so the locked-render cold-boot path can rebuild a protected key's display wallet without the secret — moved out of the SingleKeyEntry vault blob ahead of the raw-seam migration. NON-secret; #[serde(default)] for old entries. write_wallet_meta now carries uses_password/password_hint from the open Wallet; the legacy-table drain (finish_unwire) defaults them (the authoritative flag is read from the envelope at the migrating unlock). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/backend_task/migration/finish_unwire.rs | 8 ++ src/context/wallet_lifecycle.rs | 4 + src/model/single_key.rs | 8 ++ src/model/wallet/meta.rs | 146 +++++++++++++++++++- src/wallet_backend/hydration.rs | 16 +++ src/wallet_backend/single_key.rs | 3 + src/wallet_backend/wallet_meta.rs | 24 +++- 7 files changed, 202 insertions(+), 7 deletions(-) diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index fb00f1de0..bb72ef607 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -931,6 +931,12 @@ where is_main: is_main.unwrap_or(false), core_wallet_name, xpub_encoded, + // The legacy `wallet` table does not carry the password flag/hint + // (they lived in the seed envelope). The authoritative value is + // read from the envelope at the migrating unlock; default to "no + // extra prompt" here. + uses_password: false, + password_hint: None, }; match set(seed_hash, meta) { @@ -2192,6 +2198,8 @@ mod tests { is_main: true, core_wallet_name: Some("dev-dashd".into()), xpub_encoded: Vec::new(), + uses_password: false, + password_hint: None, }) ); // Mainnet row must not be visible on testnet. diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index ee14bb335..24fd46d4a 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -583,6 +583,8 @@ impl AppContext { .master_bip44_ecdsa_extended_public_key .encode() .to_vec(), + uses_password: wallet.uses_password, + password_hint: wallet.password_hint().clone(), }; WalletMetaView::new(&self.app_kv).set(self.network, &seed_hash, &meta) } @@ -1866,6 +1868,8 @@ mod tests { is_main: false, core_wallet_name: None, xpub_encoded: det_master_bip44.encode().to_vec(), + uses_password: false, + password_hint: None, }, ) .expect("write wallet-meta sidecar"); diff --git a/src/model/single_key.rs b/src/model/single_key.rs index f06ef9357..9e91df15d 100644 --- a/src/model/single_key.rs +++ b/src/model/single_key.rs @@ -49,4 +49,12 @@ pub struct ImportedKey { /// `None` for legacy entries that pre-date the per-key passphrase. #[serde(default)] pub passphrase_hint: Option, + /// Compressed SEC1-encoded **public** key for this imported key. The + /// locked-render cold-boot path needs it to rebuild a passphrase-protected + /// key's display wallet without the secret (moved here from the + /// `SingleKeyEntry` vault blob under the raw-seam migration). Empty for + /// entries written before this field — the caller falls back to deriving + /// from plaintext when the key is unlocked. NON-secret. + #[serde(default)] + pub public_key_bytes: Vec, } diff --git a/src/model/wallet/meta.rs b/src/model/wallet/meta.rs index 2ebf7c4f1..68833cee0 100644 --- a/src/model/wallet/meta.rs +++ b/src/model/wallet/meta.rs @@ -19,6 +19,52 @@ use serde::{Deserialize, Serialize}; +/// On-disk version tag for the bincode-encoded [`WalletMeta`] payload, framed +/// by [`WalletMetaView`](crate::wallet_backend::WalletMetaView) as +/// `[ WALLET_META_VERSION (1B) | bincode(WalletMeta) ]`. +/// +/// `WalletMeta` is positional bincode, so adding a field is format-breaking for +/// already-stored blobs — `#[serde(default)]` alone does NOT make a stored blob +/// forward-compatible (it only supplies a value at the Rust layer when a field +/// is genuinely absent from the encoded stream, which positional bincode never +/// reports). This explicit version byte is the gate: v1 is the original shape +/// (no `uses_password` / `password_hint`); v2 adds them. The reader detects the +/// version and migrates a v1 blob to v2 with the new fields defaulted, rather +/// than positionally misparsing it. +pub const WALLET_META_VERSION: u8 = 2; + +/// The original (pre-`uses_password`) [`WalletMeta`] on-disk shape. Retained +/// decode-only so a v1 blob (or a pre-version-byte legacy blob) migrates into +/// the current shape instead of being misread. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WalletMetaV1 { + /// See [`WalletMeta::alias`]. + pub alias: String, + /// See [`WalletMeta::is_main`]. + pub is_main: bool, + /// See [`WalletMeta::core_wallet_name`]. + pub core_wallet_name: Option, + /// See [`WalletMeta::xpub_encoded`]. + #[serde(default)] + pub xpub_encoded: Vec, +} + +impl From for WalletMeta { + fn from(v1: WalletMetaV1) -> Self { + WalletMeta { + alias: v1.alias, + is_main: v1.is_main, + core_wallet_name: v1.core_wallet_name, + xpub_encoded: v1.xpub_encoded, + // A v1 blob predates the password sidecar. The unlock/migration + // path reads the authoritative flag from the legacy envelope; this + // default is the safe "ask nothing extra" starting point. + uses_password: false, + password_hint: None, + } + } +} + /// DET-owned per-wallet metadata. /// /// Lives next to the upstream wallet state, not inside it: upstream @@ -52,10 +98,58 @@ pub struct WalletMeta { /// positional `bincode::config::standard()` blob behind the `DetKv` /// schema envelope, so adding, removing, or reordering any field here is /// a format-breaking change for already-stored blobs. Evolve the shape - /// only by bumping `crate::wallet_backend::kv::SCHEMA_VERSION` and - /// migrating old blobs. + /// only by bumping [`WALLET_META_VERSION`] and migrating old blobs (see + /// [`WalletMetaV1`]). #[serde(default)] pub xpub_encoded: Vec, + /// `true` when the wallet's seed was stored under a user password. Moved + /// out of the legacy seed envelope into this non-secret sidecar. After the + /// raw-seam migration this flips to `false` (the password no longer gates + /// the at-rest secret) — see the migration's lazy-unlock path. + #[serde(default)] + pub uses_password: bool, + /// Optional user-set password hint, moved out of the legacy seed envelope. + /// Shown next to the unlock prompt for a not-yet-migrated password wallet. + #[serde(default)] + pub password_hint: Option, +} + +/// Encode a [`WalletMeta`] for storage as `[ WALLET_META_VERSION | bincode ]`. +/// The leading version byte lets the reader migrate older shapes instead of +/// positionally misparsing them. +pub fn encode_versioned(meta: &WalletMeta) -> Result, bincode::error::EncodeError> { + let body = bincode::serde::encode_to_vec(meta, bincode::config::standard())?; + let mut out = Vec::with_capacity(body.len() + 1); + out.push(WALLET_META_VERSION); + out.extend_from_slice(&body); + Ok(out) +} + +/// Decode a stored [`WalletMeta`] payload, handling every on-disk shape: +/// +/// * leading [`WALLET_META_VERSION`] (current v2) → decode directly; +/// * leading version byte `1` → decode as [`WalletMetaV1`] and migrate; +/// * no recognised version byte (pre-version-byte legacy blob) → try v1 bare +/// bincode and migrate. +/// +/// A blob that matches none of these is a decode error — never a positional +/// misparse. +pub fn decode_versioned(bytes: &[u8]) -> Result { + let cfg = bincode::config::standard(); + if let Some((&tag, rest)) = bytes.split_first() { + if tag == WALLET_META_VERSION { + let (meta, _) = bincode::serde::decode_from_slice::(rest, cfg)?; + return Ok(meta); + } + if tag == 1 + && let Ok((v1, _)) = bincode::serde::decode_from_slice::(rest, cfg) + { + return Ok(v1.into()); + } + } + // Pre-version-byte legacy blob: bare v1 bincode. + let (v1, _) = bincode::serde::decode_from_slice::(bytes, cfg)?; + Ok(v1.into()) } #[cfg(test)] @@ -72,6 +166,8 @@ mod tests { is_main: true, core_wallet_name: Some("dev-wallet".into()), xpub_encoded: vec![0xAB; 78], + uses_password: true, + password_hint: Some("granny's birthday".into()), }; let bytes = bincode::serde::encode_to_vec(&original, bincode::config::standard()).expect("encode"); @@ -81,7 +177,8 @@ mod tests { } /// W-META-002 — `Default` matches the "fresh install, never named" - /// shape: empty alias, not main, no Dash Core wallet link, no xpub. + /// shape: empty alias, not main, no Dash Core wallet link, no xpub, no + /// password. #[test] fn default_is_empty_unnamed_wallet() { let m = WalletMeta::default(); @@ -89,5 +186,48 @@ mod tests { assert!(!m.is_main); assert!(m.core_wallet_name.is_none()); assert!(m.xpub_encoded.is_empty()); + assert!(!m.uses_password); + assert!(m.password_hint.is_none()); + } + + /// TS-META-01 — the new v2 shape round-trips through the versioned framing + /// field-for-field, and an OLD v1 blob is detected by its version byte and + /// migrated (NOT positionally misparsed). The migrated meta defaults + /// `uses_password=false` / `password_hint=None` and carries every v1 field. + #[test] + fn ts_meta_01_versioned_frame_round_trip_and_v1_migration() { + let v2 = WalletMeta { + alias: "paycheque".into(), + is_main: true, + core_wallet_name: Some("dev-wallet".into()), + xpub_encoded: vec![0xCD; 78], + uses_password: true, + password_hint: Some("hint".into()), + }; + let framed = encode_versioned(&v2).expect("encode v2"); + assert_eq!(framed[0], WALLET_META_VERSION, "frame starts with the version tag"); + assert_eq!(decode_versioned(&framed).expect("decode v2"), v2); + + // A v1 blob: framed with version byte 1 over the old shape. + let v1 = WalletMetaV1 { + alias: "legacy".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: vec![0x22; 78], + }; + let v1_body = + bincode::serde::encode_to_vec(&v1, bincode::config::standard()).expect("encode v1"); + let mut v1_framed = vec![1u8]; + v1_framed.extend_from_slice(&v1_body); + let migrated = decode_versioned(&v1_framed).expect("decode + migrate v1"); + assert_eq!(migrated.alias, "legacy"); + assert_eq!(migrated.xpub_encoded, vec![0x22; 78]); + assert!(!migrated.uses_password, "v1 migrates with uses_password defaulted false"); + assert!(migrated.password_hint.is_none()); + + // A pre-version-byte legacy blob (bare v1 bincode) also migrates. + let bare = decode_versioned(&v1_body).expect("decode + migrate bare v1"); + assert_eq!(bare.alias, "legacy"); + assert_eq!(WalletMeta::from(v1), bare); } } diff --git a/src/wallet_backend/hydration.rs b/src/wallet_backend/hydration.rs index b9dd1ab31..3a16a579c 100644 --- a/src/wallet_backend/hydration.rs +++ b/src/wallet_backend/hydration.rs @@ -263,6 +263,8 @@ mod tests { is_main: true, core_wallet_name: Some("local-dashd".into()), xpub_encoded: xpub, + uses_password: false, + password_hint: None, }; // Stand-in for `WalletSeedView::get` — direct decode of the @@ -303,6 +305,8 @@ mod tests { is_main: false, core_wallet_name: None, xpub_encoded: xpub, + uses_password: false, + password_hint: None, }; let master = ExtendedPubKey::decode(&envelope.xpub_encoded).expect("xpub decodes"); @@ -337,6 +341,8 @@ mod tests { is_main: false, core_wallet_name: None, xpub_encoded: xpub, + uses_password: false, + password_hint: None, }; let master = ExtendedPubKey::decode(&envelope.xpub_encoded).expect("xpub decodes"); let wallet = wallet_from_envelope(seed_hash_for(seed), envelope, &meta, master) @@ -378,6 +384,8 @@ mod tests { is_main: true, core_wallet_name: None, xpub_encoded: xpub, + uses_password: false, + password_hint: None, }; let wallet = reconstruct_wallet(&view, &hash, &meta) @@ -405,6 +413,8 @@ mod tests { is_main: false, core_wallet_name: None, xpub_encoded: xpub, + uses_password: false, + password_hint: None, }; let result = reconstruct_wallet(&view, &seed_hash_for(seed), &meta).expect("no error"); assert!(result.is_none(), "missing envelope must collapse to None"); @@ -434,6 +444,8 @@ mod tests { is_main: false, core_wallet_name: None, xpub_encoded: Vec::new(), + uses_password: false, + password_hint: None, }; let result = reconstruct_wallet(&view, &hash, &meta).expect("no error"); assert!(result.is_none(), "empty xpub must collapse to None"); @@ -460,6 +472,8 @@ mod tests { is_main: false, core_wallet_name: None, xpub_encoded: xpub.clone(), + uses_password: false, + password_hint: None, }; let master = ExtendedPubKey::decode(&xpub).expect("xpub decodes"); let err = wallet_from_envelope(seed_hash_for(seed), envelope, &meta, master) @@ -499,6 +513,8 @@ mod tests { is_main: true, core_wallet_name: None, xpub_encoded: xpub.clone(), + uses_password: false, + password_hint: None, }; let master = ExtendedPubKey::decode(&xpub).expect("xpub decodes"); let mut wallet = wallet_from_envelope(seed_hash_for(seed), envelope, &meta, master) diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs index 9db6bbf04..8c09b0735 100644 --- a/src/wallet_backend/single_key.rs +++ b/src/wallet_backend/single_key.rs @@ -237,6 +237,7 @@ impl<'a> SingleKeyView<'a> { network: self.network, has_passphrase: entry.has_passphrase, passphrase_hint: entry.passphrase_hint.clone(), + public_key_bytes: pub_key.inner.serialize().to_vec(), }; if let Some(kv) = self.app_kv { @@ -1218,6 +1219,7 @@ mod tests { network, has_passphrase: false, passphrase_hint: None, + public_key_bytes: Vec::new(), }; kv.put( DetScope::Global, @@ -1515,6 +1517,7 @@ mod tests { network, has_passphrase: false, passphrase_hint: None, + public_key_bytes: Vec::new(), }; kv.put(DetScope::Global, &meta_key_for(network, &address), &meta) .expect("seed sidecar"); diff --git a/src/wallet_backend/wallet_meta.rs b/src/wallet_backend/wallet_meta.rs index da551ed7a..e719448de 100644 --- a/src/wallet_backend/wallet_meta.rs +++ b/src/wallet_backend/wallet_meta.rs @@ -30,7 +30,7 @@ use dash_sdk::dpp::dashcore::base58; use crate::backend_task::error::TaskError; use crate::model::wallet::WalletSeedHash; -use crate::model::wallet::meta::WalletMeta; +use crate::model::wallet::meta::{WalletMeta, decode_versioned, encode_versioned}; use crate::wallet_backend::kv::KvAdapterError; use crate::wallet_backend::{DetKv, DetScope}; @@ -111,7 +111,7 @@ impl<'a> WalletMetaView<'a> { ); continue; }; - match self.kv.get::(DetScope::Global, &key) { + match self.read_meta(&key) { Ok(Some(meta)) => out.push((hash, meta)), Ok(None) => {} Err(e) => { @@ -131,7 +131,7 @@ impl<'a> WalletMetaView<'a> { /// absent or the blob fails to decode (logged). pub fn get(&self, network: Network, seed_hash: &WalletSeedHash) -> Option { let key = key_for(network, seed_hash); - match self.kv.get::(DetScope::Global, &key) { + match self.read_meta(&key) { Ok(v) => v, Err(e) => { tracing::warn!( @@ -154,11 +154,25 @@ impl<'a> WalletMetaView<'a> { meta: &WalletMeta, ) -> Result<(), TaskError> { let key = key_for(network, seed_hash); + let framed = encode_versioned(meta) + .map_err(|e| map_kv_error_to_task_error(KvAdapterError::Encode(e)))?; self.kv - .put(DetScope::Global, &key, meta) + .put(DetScope::Global, &key, &framed) .map_err(map_kv_error_to_task_error) } + /// Read and version-decode a single wallet-meta blob, migrating a v1 (or + /// pre-version-byte legacy) shape into the current [`WalletMeta`]. + /// `Ok(None)` when the key is absent. + fn read_meta(&self, key: &str) -> Result, KvAdapterError> { + let Some(framed) = self.kv.get::>(DetScope::Global, key)? else { + return Ok(None); + }; + decode_versioned(&framed) + .map(Some) + .map_err(KvAdapterError::Decode) + } + /// Delete the metadata for a single wallet. Idempotent — a /// missing key returns `Ok(())`. pub fn delete(&self, network: Network, seed_hash: &WalletSeedHash) -> Result<(), TaskError> { @@ -258,6 +272,8 @@ mod tests { is_main, core_wallet_name: core.map(str::to_string), xpub_encoded: Vec::new(), + uses_password: false, + password_hint: None, } } From 1880461826cd19b394e33b280a6af10ee753f422 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:04:24 +0200 Subject: [PATCH 09/24] chore(wallet-backend): satisfy fmt + clippy for the secret-seam batch - leak_test_support: drop redundant inner #![cfg(test)] (mod.rs already gates it). - encrypted_key_storage: factor take_plaintext_for_vault's return into the VaultBoundKey type alias (clippy::type_complexity). - wallet_hydration bench: carry the new WalletMeta password fields. - nightly-fmt whitespace. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 944 lib + 146 + 10 + 3 + 2 pass, 0 fail; 2 compile_fail doctests pass; det-cli standalone smoke (network-info / tools / core-wallets-list) all green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- benches/wallet_hydration.rs | 2 ++ .../encrypted_key_storage.rs | 19 ++++++++++++++----- src/model/wallet/meta.rs | 10 ++++++++-- src/model/wallet/single_key.rs | 4 +++- src/ui/identities/keys/key_info_screen.rs | 6 ++++-- src/wallet_backend/leak_test_support.rs | 2 -- src/wallet_backend/secret_seam.rs | 5 ++++- 7 files changed, 35 insertions(+), 13 deletions(-) diff --git a/benches/wallet_hydration.rs b/benches/wallet_hydration.rs index 71e193758..e16e1e268 100644 --- a/benches/wallet_hydration.rs +++ b/benches/wallet_hydration.rs @@ -122,6 +122,8 @@ fn seed_hd_wallets( is_main: i == 0, core_wallet_name: None, xpub_encoded: xpub, + uses_password: false, + password_hint: None, }; let seed_hash = wallet.seed_hash(); seed_view.set(&seed_hash, &envelope).expect("set envelope"); diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index 8e1a46137..f3c715e3b 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -20,6 +20,11 @@ use zeroize::Zeroizing; /// dropped. pub type ResolvedPrivateKey = (QualifiedIdentityPublicKey, Zeroizing<[u8; 32]>); +/// A `(target, key_id)` map key paired with the raw 32-byte private key the +/// migration must store in the vault — see +/// [`KeyStorage::take_plaintext_for_vault`]. Bytes are [`Zeroizing`]. +pub type VaultBoundKey = ((PrivateKeyTarget, KeyID), Zeroizing<[u8; 32]>); + #[derive(Debug, Clone, PartialEq)] pub struct WalletDerivationPath { pub(crate) wallet_seed_hash: WalletSeedHash, @@ -554,9 +559,7 @@ impl KeyStorage { /// Wallet-derived ([`PrivateKeyData::AtWalletDerivationPath`]) and already /// vault-backed / encrypted keys are left untouched — they were never /// plaintext-at-rest. - pub fn take_plaintext_for_vault( - &mut self, - ) -> Vec<((PrivateKeyTarget, KeyID), Zeroizing<[u8; 32]>)> { + pub fn take_plaintext_for_vault(&mut self) -> Vec { let mut out = Vec::new(); for (map_key, (_pub_key, data)) in self.private_keys.iter_mut() { let raw = match data { @@ -711,7 +714,10 @@ mod tests { let bytes = bincode::encode_to_vec(&original, cfg).expect("encode"); let (decoded, _): (PrivateKeyData, _) = bincode::decode_from_slice(&bytes, cfg).expect("decode old variant"); - assert!(decoded == original, "pre-InVault variant must decode unchanged"); + assert!( + decoded == original, + "pre-InVault variant must decode unchanged" + ); } // The new variant round-trips too. let bytes = bincode::encode_to_vec(PrivateKeyData::InVault, cfg).expect("encode"); @@ -770,7 +776,10 @@ mod tests { // The two plaintext keys are now InVault; the derived one is not. let mut in_vault_count = 0; for k in &keys { - assert!(ks.public_key_for(k).is_some(), "public key always available"); + assert!( + ks.public_key_for(k).is_some(), + "public key always available" + ); if ks.is_in_vault(k) { in_vault_count += 1; } diff --git a/src/model/wallet/meta.rs b/src/model/wallet/meta.rs index 68833cee0..375ad084d 100644 --- a/src/model/wallet/meta.rs +++ b/src/model/wallet/meta.rs @@ -205,7 +205,10 @@ mod tests { password_hint: Some("hint".into()), }; let framed = encode_versioned(&v2).expect("encode v2"); - assert_eq!(framed[0], WALLET_META_VERSION, "frame starts with the version tag"); + assert_eq!( + framed[0], WALLET_META_VERSION, + "frame starts with the version tag" + ); assert_eq!(decode_versioned(&framed).expect("decode v2"), v2); // A v1 blob: framed with version byte 1 over the old shape. @@ -222,7 +225,10 @@ mod tests { let migrated = decode_versioned(&v1_framed).expect("decode + migrate v1"); assert_eq!(migrated.alias, "legacy"); assert_eq!(migrated.xpub_encoded, vec![0x22; 78]); - assert!(!migrated.uses_password, "v1 migrates with uses_password defaulted false"); + assert!( + !migrated.uses_password, + "v1 migrates with uses_password defaulted false" + ); assert!(migrated.password_hint.is_none()); // A pre-version-byte legacy blob (bare v1 bincode) also migrates. diff --git a/src/model/wallet/single_key.rs b/src/model/wallet/single_key.rs index 28815c777..6be7d78a2 100644 --- a/src/model/wallet/single_key.rs +++ b/src/model/wallet/single_key.rs @@ -459,7 +459,9 @@ mod tests { /// that holds it. #[test] fn ts_dbg_01_closed_single_key_debug_redacts_raw_bytes() { - use crate::wallet_backend::leak_test_support::{assert_no_leak_bytes, distinctive_secret_32}; + use crate::wallet_backend::leak_test_support::{ + assert_no_leak_bytes, distinctive_secret_32, + }; let secret = distinctive_secret_32(); // A no-password / pre-migration closed key holds the raw 32 bytes in diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index e626fbde9..8b3e70dec 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -447,8 +447,10 @@ impl ScreenLike for KeyInfoScreen { // WalletTasks (T8 follow-up); until those land, the // key is shown as securely stored. ui.label( - RichText::new("This signing key is stored securely on this device.") - .color(text_primary), + RichText::new( + "This signing key is stored securely on this device.", + ) + .color(text_primary), ); ui.add_space(10.0); } diff --git a/src/wallet_backend/leak_test_support.rs b/src/wallet_backend/leak_test_support.rs index 1cbc0744a..f0be81a30 100644 --- a/src/wallet_backend/leak_test_support.rs +++ b/src/wallet_backend/leak_test_support.rs @@ -9,8 +9,6 @@ //! leaks the `[160, 167, …]` decimal form, and finding `6a2818cd` leaked //! exactly that. Hex alone would falsely pass against that bug. -#![cfg(test)] - /// Assert `rendered` exposes `secret` in NONE of the forms a sink could leak /// it: lowercase hex and the `[160, 167, …]` decimal-array form. Works for any /// secret length (32-byte keys, 64-byte seeds). diff --git a/src/wallet_backend/secret_seam.rs b/src/wallet_backend/secret_seam.rs index 1852ad696..a2fbcf41e 100644 --- a/src/wallet_backend/secret_seam.rs +++ b/src/wallet_backend/secret_seam.rs @@ -168,7 +168,10 @@ mod tests { seam.put_secret(&scope, label, &SecretBytes::from_slice(&key)) .expect("put"); - let got = seam.get_secret(&scope, label).expect("get").expect("present"); + let got = seam + .get_secret(&scope, label) + .expect("get") + .expect("present"); assert_eq!(got.expose_secret(), &key[..]); assert_eq!( got.expose_secret().len(), From e503bbd8d465b2d6611886a70ea829faac2c53ae Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:13:32 +0200 Subject: [PATCH 10/24] feat(wallet-backend): SecretScope::IdentityKey + seam-first SecretAccess (T3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chokepoint learns identity keys and goes seam-first for everyone. - SecretScope::IdentityKey { identity_id:[u8;32], target, key_id } (DET-opaque; KeyID is just u32, PrivateKeyTarget is a DET model enum). identity_key_label() builds identity_key_priv.. — a stable one-char target tag keeps the label inside the upstream allowlist. - SecretPlaintext::IdentityKey + expose_identity_key; Plaintext::IdentityKey. Borrowed-only, zeroizing, never resident — same hygiene as the other kinds. - decrypt_jit is now SEAM-FIRST for all three classes: the raw label wins; the retained legacy reader (decrypt_hd_seed / SingleKeyEntry::decrypt) is the migration fallback for HD seeds and single keys. IdentityKey reads raw via the seam → loud IdentityKeyMissing if absent (never silent). - scope_has_passphrase: a migrated raw secret reports false (the password no longer gates it); only a not-yet-migrated legacy entry can still be protected; IdentityKey is always false → prompt-free fast-path → headless/MCP signing works. - DetSigner treats an IdentityKey plaintext as a raw single key (same secp256k1 shape, no derivation tree). Tests: TS-FAST-01 (identity key resolves prompt-free, ask_count 0, can_resolve_without_prompt true), IdentityKeyMissing is loud, TS-LEGACY-01 (legacy envelope served when raw absent), raw-wins-over-legacy precedence. The pre-existing protected-HD/single-key tests now exercise the legacy fallback. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/wallet_backend/det_signer.rs | 8 +- src/wallet_backend/secret_access.rs | 262 +++++++++++++++++++++++++++- src/wallet_backend/secret_prompt.rs | 32 ++++ 3 files changed, 295 insertions(+), 7 deletions(-) diff --git a/src/wallet_backend/det_signer.rs b/src/wallet_backend/det_signer.rs index 7de113b86..6fdf5c3c3 100644 --- a/src/wallet_backend/det_signer.rs +++ b/src/wallet_backend/det_signer.rs @@ -92,7 +92,13 @@ impl<'a> DetSigner<'a> { pub(crate) fn from_held(plaintext: SecretPlaintext<'a>, network: Network) -> Self { let secret = match plaintext { SecretPlaintext::HdSeed(seed) => HeldSecret::HdSeed(seed), - SecretPlaintext::SingleKey(key) => HeldSecret::SingleKey(key), + // An identity key is a raw secp256k1 secret, same shape as a + // single key (no derivation tree) — `DetSigner` treats them + // identically. Identity-platform signing normally goes straight + // through the resolver, not here. + SecretPlaintext::SingleKey(key) | SecretPlaintext::IdentityKey(key) => { + HeldSecret::SingleKey(key) + } }; Self { secret, diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index 3a9407fd2..8e97512d5 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -42,8 +42,7 @@ use std::time::Instant; use aes_gcm::aead::Aead; use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; use dash_sdk::dpp::dashcore::Network; -use platform_wallet_storage::secrets::SecretStore; -use platform_wallet_storage::secrets::SecretString; +use platform_wallet_storage::secrets::{SecretStore, SecretString, WalletId as SecretWalletId}; use zeroize::Zeroizing; use crate::backend_task::error::TaskError; @@ -54,6 +53,7 @@ use crate::model::wallet::seed_envelope::StoredSeedEnvelope; use crate::wallet_backend::secret_prompt::{ RememberPolicy, SecretPrompt, SecretPromptRequest, SecretPromptRetry, SecretScope, }; +use crate::wallet_backend::secret_seam::SecretSeam; use crate::wallet_backend::single_key::{label_for_address, single_key_namespace_id}; use crate::wallet_backend::single_key_entry::SingleKeyEntry; use crate::wallet_backend::wallet_seed_store::WalletSeedView; @@ -62,6 +62,15 @@ use crate::wallet_backend::wallet_seed_store::WalletSeedView; const HD_SEED_LEN: usize = 64; /// Length of an imported single-key secret. const SINGLE_KEY_LEN: usize = 32; +/// Vault label for a raw (migrated) HD seed, distinct from the legacy +/// `envelope.v1` so the loader can tell raw from legacy by label presence. +pub(crate) const SEED_RAW_LABEL: &str = "seed.raw.v1"; + +/// The vault scope for an HD seed — the 32-byte seed hash reused as the +/// upstream `WalletId`. +fn seed_scope(seed_hash: &WalletSeedHash) -> SecretWalletId { + SecretWalletId::from(*seed_hash) +} /// Borrowed, kind-tagged plaintext handed to a [`SecretAccess::with_secret`] /// closure. Lives only for the closure call. No `Clone`, no `Deref` to raw @@ -73,6 +82,8 @@ pub enum SecretPlaintext<'a> { HdSeed(&'a Zeroizing<[u8; HD_SEED_LEN]>), /// A 32-byte imported single-key secret. SingleKey(&'a Zeroizing<[u8; SINGLE_KEY_LEN]>), + /// A 32-byte identity private key, read raw from the vault per-use. + IdentityKey(&'a Zeroizing<[u8; SINGLE_KEY_LEN]>), } impl SecretPlaintext<'_> { @@ -84,7 +95,7 @@ impl SecretPlaintext<'_> { // implements `AsRef` (dashcore), which makes a bare // `.as_ref()` ambiguous. SecretPlaintext::HdSeed(s) => Some(&***s), - SecretPlaintext::SingleKey(_) => None, + _ => None, } } @@ -93,7 +104,17 @@ impl SecretPlaintext<'_> { pub fn expose_single_key(&self) -> Option<&[u8; SINGLE_KEY_LEN]> { match self { SecretPlaintext::SingleKey(k) => Some(&***k), - SecretPlaintext::HdSeed(_) => None, + _ => None, + } + } + + /// Borrow the 32-byte identity private key, or `None` for the other + /// kinds. The plaintext is borrowed for the closure only and zeroizes + /// on return — it is never resident. + pub fn expose_identity_key(&self) -> Option<&[u8; SINGLE_KEY_LEN]> { + match self { + SecretPlaintext::IdentityKey(k) => Some(&***k), + _ => None, } } } @@ -121,6 +142,7 @@ impl SecretSession<'_> { enum Plaintext { HdSeed(Zeroizing<[u8; HD_SEED_LEN]>), SingleKey(Zeroizing<[u8; SINGLE_KEY_LEN]>), + IdentityKey(Zeroizing<[u8; SINGLE_KEY_LEN]>), } impl Plaintext { @@ -128,6 +150,7 @@ impl Plaintext { match self { Plaintext::HdSeed(s) => SecretPlaintext::HdSeed(s), Plaintext::SingleKey(k) => SecretPlaintext::SingleKey(k), + Plaintext::IdentityKey(k) => SecretPlaintext::IdentityKey(k), } } @@ -138,6 +161,7 @@ impl Plaintext { match self { Plaintext::HdSeed(s) => Plaintext::HdSeed(Zeroizing::new(**s)), Plaintext::SingleKey(k) => Plaintext::SingleKey(Zeroizing::new(**k)), + Plaintext::IdentityKey(k) => Plaintext::IdentityKey(Zeroizing::new(**k)), } } } @@ -369,6 +393,7 @@ impl SecretAccess { let owned = match plaintext { SecretPlaintext::HdSeed(s) => Plaintext::HdSeed(Zeroizing::new(**s)), SecretPlaintext::SingleKey(k) => Plaintext::SingleKey(Zeroizing::new(**k)), + SecretPlaintext::IdentityKey(k) => Plaintext::IdentityKey(Zeroizing::new(**k)), }; self.maybe_remember(scope, &owned, policy); } @@ -464,6 +489,7 @@ impl SecretAccess { let boxed = match plaintext { Plaintext::HdSeed(s) => Box::new(Plaintext::HdSeed(Zeroizing::new(**s))), Plaintext::SingleKey(k) => Box::new(Plaintext::SingleKey(Zeroizing::new(**k))), + Plaintext::IdentityKey(k) => Box::new(Plaintext::IdentityKey(Zeroizing::new(**k))), }; if let Ok(mut guard) = self.inner.session.write() { guard.insert( @@ -491,16 +517,28 @@ impl SecretAccess { } /// Whether `scope`'s stored secret is passphrase-protected. Drives the - /// unprotected fast-path (Smythe must-fix #4). Reads the in-memory - /// index/meta where possible; falls back to the stored envelope. + /// unprotected fast-path (Smythe must-fix #4). + /// + /// Seam-first: a secret already migrated to its raw label has no + /// passphrase (the user password no longer gates it). Only a not-yet- + /// migrated legacy entry can still be protected. Identity keys are always + /// unprotected (prompt-free → headless/MCP signing works). fn scope_has_passphrase(&self, scope: &SecretScope) -> Result { match scope { SecretScope::HdSeed { seed_hash } => { + // Raw seed present ⇒ migrated ⇒ no passphrase. + if self.seam().get_secret(&seed_scope(seed_hash), SEED_RAW_LABEL)?.is_some() { + return Ok(false); + } let view = WalletSeedView::new(&self.inner.secret_store); let envelope = view.get(seed_hash)?.ok_or(TaskError::WalletNotFound)?; Ok(envelope.uses_password) } SecretScope::SingleKey { address } => { + // Raw 32-byte key present ⇒ migrated ⇒ no passphrase. + if self.single_key_raw(address)?.is_some() { + return Ok(false); + } if let Ok(index) = self.inner.single_key_index.read() && let Some(meta) = index.get(address) { @@ -509,12 +547,17 @@ impl SecretAccess { let entry = self.load_single_key_entry(address)?; Ok(entry.has_passphrase) } + // Identity keys are stored raw, unprotected — always prompt-free. + SecretScope::IdentityKey { .. } => Ok(false), } } /// Decrypt the stored secret for `scope` with `passphrase` /// (`None` for unprotected scopes). The only place the vault is read /// for plaintext. Returns the kind-tagged owned plaintext. + /// + /// Seam-first for all three classes: the raw label wins; the retained + /// legacy reader is the migration fallback for HD seeds and single keys. fn decrypt_jit( &self, scope: &SecretScope, @@ -522,16 +565,77 @@ impl SecretAccess { ) -> Result { match scope { SecretScope::HdSeed { seed_hash } => { + if let Some(raw) = + self.seam().get_secret(&seed_scope(seed_hash), SEED_RAW_LABEL)? + { + let seed: [u8; HD_SEED_LEN] = + raw.expose_secret().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::secret_access", + blob_len = raw.expose_secret().len(), + "Raw seam seed has wrong length", + ); + TaskError::SecretDecryptFailed + })?; + return Ok(Plaintext::HdSeed(Zeroizing::new(seed))); + } + // Legacy fallback (migration reader). let view = WalletSeedView::new(&self.inner.secret_store); let envelope = view.get(seed_hash)?.ok_or(TaskError::WalletNotFound)?; let seed = decrypt_hd_seed(&envelope, passphrase)?; Ok(Plaintext::HdSeed(seed)) } SecretScope::SingleKey { address } => { + if let Some(raw) = self.single_key_raw(address)? { + return Ok(Plaintext::SingleKey(raw)); + } + // Legacy fallback (migration reader). let entry = self.load_single_key_entry(address)?; let raw = entry.decrypt(passphrase.map(|p| p.expose_secret()))?; Ok(Plaintext::SingleKey(raw)) } + SecretScope::IdentityKey { + identity_id, + target, + key_id, + } => { + let label = SecretScope::identity_key_label(target, *key_id); + let raw = self + .seam() + .get_secret(&SecretWalletId::from(*identity_id), &label)? + .ok_or(TaskError::IdentityKeyMissing)?; + let key: [u8; SINGLE_KEY_LEN] = + raw.expose_secret().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::secret_access", + blob_len = raw.expose_secret().len(), + "Raw identity key has wrong length", + ); + TaskError::SecretDecryptFailed + })?; + Ok(Plaintext::IdentityKey(Zeroizing::new(key))) + } + } + } + + /// Borrow the secret store as a [`SecretSeam`]. + fn seam(&self) -> SecretSeam<'_> { + SecretSeam::new(&self.inner.secret_store) + } + + /// Read the raw 32-byte single-key secret for `address` if the entry has + /// already been migrated to its raw label, else `None`. A legacy + /// `SingleKeyEntry`-framed value (length != 32) is left for the legacy + /// reader and reported as `None` here. + fn single_key_raw(&self, address: &str) -> Result>, TaskError> { + let label = label_for_address(address); + let Some(payload) = self.seam().get_secret(&single_key_namespace_id(), &label)? else { + return Ok(None); + }; + match <[u8; SINGLE_KEY_LEN]>::try_from(payload.expose_secret()) { + Ok(raw) => Ok(Some(Zeroizing::new(raw))), + // Not 32 bytes ⇒ a legacy framed entry, not yet migrated. + Err(_) => Ok(None), } } @@ -582,6 +686,10 @@ impl SecretAccess { let hint = meta.and_then(|m| m.passphrase_hint); (label, hint) } + // Identity keys are prompt-free (unprotected fast-path), so this + // request is never built for them — a generic label keeps the + // match exhaustive without inventing copy that cannot surface. + SecretScope::IdentityKey { .. } => ("this identity key".to_string(), None), }; let mut request = SecretPromptRequest::new(scope.clone(), label).with_hint(hint); if let Some(reason) = retry { @@ -1259,4 +1367,146 @@ mod tests { assert_eq!(count, 3, "held secret borrowed N times"); assert_eq!(prompt.ask_count(), 1, "one prompt for the whole operation"); } + + // --- identity-key scope (raw seam, prompt-free) ----------------------- + + use crate::model::qualified_identity::PrivateKeyTarget; + use platform_wallet_storage::secrets::{SecretBytes, WalletId as SecretWalletId}; + + /// Store a raw identity key in the vault under the seam label, the way the + /// migration does. + fn store_identity_key( + store: &Arc, + identity_id: [u8; 32], + target: &PrivateKeyTarget, + key_id: u32, + key: &[u8; 32], + ) { + let label = SecretScope::identity_key_label(target, key_id); + SecretSeam::new(store) + .put_secret( + &SecretWalletId::from(identity_id), + &label, + &SecretBytes::from_slice(key), + ) + .expect("store identity key"); + } + + /// TS-FAST-01 — an identity-key scope resolves prompt-free under a + /// never-prompt host (the unprotected fast-path), returns the exact 32 + /// bytes, and never asks. Proves headless/MCP identity signing works. + #[tokio::test] + async fn ts_fast_01_identity_key_resolves_prompt_free() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x33u8; 32]; + let key = [0xC7u8; 32]; + store_identity_key( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 7, + &key, + ); + + // never() panics if asked — proves no prompt fires. + let prompt = Arc::new(TestPrompt::never()); + let sa = access(store, prompt.clone()); + let scope = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 7, + }; + + let matched = sa + .with_secret(&scope, |pt| { + Ok(pt.expose_identity_key().copied() == Some(key)) + }) + .await + .expect("identity key resolves prompt-free"); + assert!(matched, "closure saw the raw identity key"); + assert_eq!(prompt.ask_count(), 0, "identity key never prompts"); + assert!( + sa.can_resolve_without_prompt(&scope), + "identity key is always resolvable without a prompt" + ); + } + + /// A missing identity key surfaces the loud typed `IdentityKeyMissing`, + /// never a silent miss. + #[tokio::test] + async fn identity_key_missing_is_loud() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let sa = access(store, Arc::new(TestPrompt::never())); + let scope = SecretScope::IdentityKey { + identity_id: [0x44u8; 32], + target: PrivateKeyTarget::PrivateKeyOnVoterIdentity, + key_id: 1, + }; + let err = sa + .with_secret(&scope, |_pt| Ok(())) + .await + .expect_err("missing identity key"); + assert!( + matches!(err, TaskError::IdentityKeyMissing), + "expected IdentityKeyMissing, got {err:?}" + ); + } + + /// TS-LEGACY-01 — with only a legacy unprotected envelope present (no raw + /// `seed.raw.v1`), the seam-first reader falls through to the retained + /// legacy decoder and recovers the exact seed, prompt-free. + #[tokio::test] + async fn ts_legacy_01_hd_legacy_envelope_served_when_raw_absent() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x4E; 32]; + store_unprotected_hd(&store, &seed_hash, &SENTINEL_SEED); + + let prompt = Arc::new(TestPrompt::never()); + let sa = access(store, prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + sa.with_secret(&scope, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(SENTINEL_SEED)); + Ok(()) + }) + .await + .expect("legacy envelope served via fallback"); + assert_eq!(prompt.ask_count(), 0, "unprotected legacy ⇒ no prompt"); + } + + /// Seam-first precedence: when BOTH a raw `seed.raw.v1` and a legacy + /// envelope exist (the legal mid-migration state, TS-CRASH-01 read half), + /// the raw value wins and the legacy is not consulted. + #[tokio::test] + async fn raw_seed_wins_over_legacy_when_both_present() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x5E; 32]; + // Legacy holds one seed; raw holds a DIFFERENT one — proving which won. + let legacy_seed = [0x11u8; 64]; + store_unprotected_hd(&store, &seed_hash, &legacy_seed); + let raw_seed = [0x99u8; 64]; + SecretSeam::new(&store) + .put_secret( + &super::seed_scope(&seed_hash), + super::SEED_RAW_LABEL, + &SecretBytes::from_slice(&raw_seed), + ) + .unwrap(); + + let sa = access(store, Arc::new(TestPrompt::never())); + let scope = SecretScope::HdSeed { seed_hash }; + sa.with_secret(&scope, |pt| { + assert_eq!( + pt.expose_hd_seed().copied(), + Some(raw_seed), + "raw seam value must win over the legacy envelope" + ); + Ok(()) + }) + .await + .expect("raw wins"); + } } diff --git a/src/wallet_backend/secret_prompt.rs b/src/wallet_backend/secret_prompt.rs index a5adf696b..5ddd730bf 100644 --- a/src/wallet_backend/secret_prompt.rs +++ b/src/wallet_backend/secret_prompt.rs @@ -23,8 +23,10 @@ use std::time::Duration; use async_trait::async_trait; +use dash_sdk::dpp::identity::KeyID; use platform_wallet_storage::secrets::SecretString; +use crate::model::qualified_identity::PrivateKeyTarget; use crate::model::wallet::WalletSeedHash; /// Which secret an operation needs. DET-opaque: carries no upstream type @@ -44,6 +46,36 @@ pub enum SecretScope { /// Base58 P2PKH address — the stable per-key identifier. address: String, }, + /// An identity private key stored raw in the vault, resolved per-use + /// (the `InVault` placeholder). Unprotected — resolves prompt-free, so + /// headless/MCP identity signing keeps working. + IdentityKey { + /// 32-byte identity id (`Identifier::to_buffer()`), the vault scope. + identity_id: [u8; 32], + /// Which associated identity the key belongs to. + target: PrivateKeyTarget, + /// The key's `KeyID` within the identity. + key_id: KeyID, + }, +} + +impl SecretScope { + /// The vault label for an identity-key scope: + /// `identity_key_priv..`. The target is a stable + /// single-char tag so the label stays inside the upstream allowlist + /// `^[A-Za-z0-9._-]{1,64}$`. + pub fn identity_key_label(target: &PrivateKeyTarget, key_id: KeyID) -> String { + format!("identity_key_priv.{}.{key_id}", target_tag(target)) + } +} + +/// Stable one-char tag for a [`PrivateKeyTarget`] used in vault labels. +fn target_tag(target: &PrivateKeyTarget) -> char { + match target { + PrivateKeyTarget::PrivateKeyOnMainIdentity => 'm', + PrivateKeyTarget::PrivateKeyOnVoterIdentity => 'v', + PrivateKeyTarget::PrivateKeyOnOperatorIdentity => 'o', + } } /// How long a decrypted secret may be remembered after the operation that From aa3c34dd7cfea1930337c49a3273645bfffd17b5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:19:04 +0200 Subject: [PATCH 11/24] feat(wallet-backend): identity_key_store + seed/single-key seam-raw writes (T6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secrets start landing raw. No DET envelope for the new write paths. - New wallet_backend/identity_key_store.rs: IdentityKeyView with store/get/delete + store_all/delete_all over raw 32 bytes via SecretSeam (scope = identity_id, label identity_key_priv..). NO StoredIdentityKey envelope — the InVault marker in the QI blob is the only on-disk trace. store_all is the migration's vault-first writer (call before the blob rewrite); delete_all backs purge_identity_scope. - WalletSeedView gains set_raw/get_raw/delete_raw (raw 64-byte seed under seed.raw.v1 via the seam) + legacy_envelope_get (retained decode-only reader). - write_seed_envelope now branches: a no-password wallet writes the RAW seed (encrypted_seed_slice() is verbatim the seed); a password wallet keeps the legacy AES-GCM envelope at creation and migrates lazily at unlock (T7). - import_wif_with_passphrase: unprotected import writes RAW 32 bytes under the existing single_key_priv. label (no SingleKeyEntry framing); protected import keeps the legacy SingleKeyEntry (lazy-migrates at unlock). The locked-render pubkey rides in the ImportedKey sidecar (the T5 field). SingleKeyEntry::decode treats a bare 32-byte blob as unprotected, so a raw-written key still rebuilds + opens at cold boot. Tests: identity_key_store round-trip / scope+target isolation / store_all+ delete_all; seed raw round-trip independent of the legacy label; single-key unprotected import is exactly 32 raw bytes (no framing) and signs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/context/wallet_lifecycle.rs | 21 ++- src/wallet_backend/identity_key_store.rs | 224 +++++++++++++++++++++++ src/wallet_backend/mod.rs | 2 + src/wallet_backend/single_key.rs | 108 ++++++++--- src/wallet_backend/wallet_seed_store.rs | 80 ++++++++ 5 files changed, 411 insertions(+), 24 deletions(-) create mode 100644 src/wallet_backend/identity_key_store.rs diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 24fd46d4a..c0c8b4b21 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -552,6 +552,25 @@ impl AppContext { /// the backend, once built, reuses the very same vault handle. fn write_seed_envelope(&self, wallet: &Wallet) -> Result<(), TaskError> { let seed_hash = wallet.seed_hash(); + let view = WalletSeedView::new(&self.secret_store); + // No-password wallets store the raw 64-byte seed directly through the + // seam: `encrypted_seed_slice()` is the verbatim seed (no DET AES-GCM). + // The non-secret metadata rides in `WalletMeta` (write_wallet_meta). + if !wallet.uses_password { + let seed: [u8; 64] = + wallet + .encrypted_seed_slice() + .try_into() + .map_err(|_| TaskError::WalletSeedStorage { + source: Box::new( + platform_wallet_storage::secrets::SecretStoreError::MalformedVault, + ), + })?; + return view.set_raw(&seed_hash, &seed); + } + // Password wallets keep the legacy AES-GCM envelope at creation; they + // migrate to the raw seam lazily at the next unlock (one prompt the + // user already does). let envelope = StoredSeedEnvelope { encrypted_seed: wallet.encrypted_seed_slice().to_vec(), salt: wallet.salt().to_vec(), @@ -563,7 +582,7 @@ impl AppContext { .encode() .to_vec(), }; - WalletSeedView::new(&self.secret_store).set(&seed_hash, &envelope) + view.set(&seed_hash, &envelope) } /// Persist a newly-registered wallet's metadata (alias / is_main / diff --git a/src/wallet_backend/identity_key_store.rs b/src/wallet_backend/identity_key_store.rs new file mode 100644 index 000000000..a2e7954d7 --- /dev/null +++ b/src/wallet_backend/identity_key_store.rs @@ -0,0 +1,224 @@ +//! Raw identity-private-key storage over the secret seam. +//! +//! Each identity private key is stored as raw 32 bytes in the upstream vault +//! through [`SecretSeam`], scoped to the identity id +//! (`Identifier::to_buffer()`) under the label +//! `identity_key_priv..`. There is NO DET-side envelope — +//! the key bytes ride raw (the no-serialization invariant), and the `InVault` +//! placeholder in the `QualifiedIdentity` blob is the only on-disk marker that +//! the key exists. +//! +//! The keys are fetched per-use through +//! [`SecretAccess`](crate::wallet_backend::SecretAccess) at sign time and never +//! resident in memory as plaintext. + +use std::sync::Arc; + +use dash_sdk::dpp::identity::KeyID; +use platform_wallet_storage::secrets::{SecretBytes, SecretStore, WalletId as SecretWalletId}; +use zeroize::Zeroizing; + +use crate::backend_task::error::TaskError; +use crate::model::qualified_identity::PrivateKeyTarget; +use crate::model::qualified_identity::encrypted_key_storage::VaultBoundKey; +use crate::wallet_backend::secret_prompt::SecretScope; +use crate::wallet_backend::secret_seam::SecretSeam; + +/// Borrowed view over the secret seam for one identity's private keys. Cheap +/// to construct — callers build one per operation. +pub struct IdentityKeyView<'a> { + secret_store: &'a Arc, + /// The identity id (`Identifier::to_buffer()`) used as the vault scope. + identity_id: [u8; 32], +} + +impl<'a> IdentityKeyView<'a> { + /// Borrow the seam for the identity scoped by `identity_id`. + pub fn new(secret_store: &'a Arc, identity_id: [u8; 32]) -> Self { + Self { + secret_store, + identity_id, + } + } + + fn scope(&self) -> SecretWalletId { + SecretWalletId::from(self.identity_id) + } + + fn seam(&self) -> SecretSeam<'_> { + SecretSeam::new(self.secret_store) + } + + /// Store one identity key's raw 32 bytes, overwriting any prior value. + pub fn store( + &self, + target: &PrivateKeyTarget, + key_id: KeyID, + key: &[u8; 32], + ) -> Result<(), TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + self.seam() + .put_secret(&self.scope(), &label, &SecretBytes::from_slice(key)) + } + + /// Store every `(target, key_id) → raw 32 bytes` pair. Used by the + /// migration after `KeyStorage::take_plaintext_for_vault` — call this + /// BEFORE rewriting the QI blob (vault-first ordering). + pub fn store_all(&self, keys: &[VaultBoundKey]) -> Result<(), TaskError> { + for ((target, key_id), bytes) in keys { + self.store(target, *key_id, bytes)?; + } + Ok(()) + } + + /// Read one identity key's raw 32 bytes, or `None` if absent. Wrapped in + /// [`Zeroizing`] so it wipes on drop. + pub fn get( + &self, + target: &PrivateKeyTarget, + key_id: KeyID, + ) -> Result>, TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + let Some(bytes) = self.seam().get_secret(&self.scope(), &label)? else { + return Ok(None); + }; + let key: [u8; 32] = bytes.expose_secret().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::identity_key_store", + blob_len = bytes.expose_secret().len(), + "Stored identity key has wrong length", + ); + TaskError::SecretDecryptFailed + })?; + Ok(Some(Zeroizing::new(key))) + } + + /// Idempotent delete of one identity key. + pub fn delete(&self, target: &PrivateKeyTarget, key_id: KeyID) -> Result<(), TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + self.seam().delete_secret(&self.scope(), &label) + } + + /// Delete every `(target, key_id)` listed. Idempotent. Used on identity + /// removal (`purge_identity_scope`) to leave no orphaned raw secret. + pub fn delete_all( + &self, + keys: impl IntoIterator, + ) -> Result<(), TaskError> { + for (target, key_id) in keys { + self.delete(&target, key_id)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::single_key::open_secret_store; + + fn fresh_store(dir: &std::path::Path) -> Arc { + let path = dir.join("secrets.pwsvault"); + Arc::new(open_secret_store(&path).expect("open vault")) + } + + /// Store/get/delete round-trip for one identity key through the seam. + #[test] + fn store_get_delete_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x11u8; 32]); + let key = [0xAB; 32]; + + view.store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3, &key) + .expect("store"); + let got = view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .expect("get") + .expect("present"); + assert_eq!(*got, key); + + view.delete(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .expect("delete"); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .expect("get after delete") + .is_none() + ); + // Idempotent delete. + view.delete(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .expect("delete twice"); + } + + /// Distinct targets and identities do not collide. + #[test] + fn scopes_and_targets_do_not_collide() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let a = IdentityKeyView::new(&store, [0xA1u8; 32]); + let b = IdentityKeyView::new(&store, [0xB2u8; 32]); + + a.store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x01; 32]) + .unwrap(); + a.store(&PrivateKeyTarget::PrivateKeyOnVoterIdentity, 0, &[0x02; 32]) + .unwrap(); + b.store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x03; 32]) + .unwrap(); + + assert_eq!( + *a.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap() + .unwrap(), + [0x01; 32] + ); + assert_eq!( + *a.get(&PrivateKeyTarget::PrivateKeyOnVoterIdentity, 0) + .unwrap() + .unwrap(), + [0x02; 32], + "distinct targets under one identity do not collide" + ); + assert_eq!( + *b.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap() + .unwrap(), + [0x03; 32], + "distinct identity scopes do not collide" + ); + } + + /// `store_all` / `delete_all` operate over the migration's bound-key list. + #[test] + fn store_all_then_delete_all() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0xCC; 32]); + let bound: Vec = vec![ + ( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + Zeroizing::new([0x10; 32]), + ), + ( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 2), + Zeroizing::new([0x20; 32]), + ), + ]; + view.store_all(&bound).expect("store_all"); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .unwrap() + .is_some() + ); + + view.delete_all([ + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 2), + ]) + .expect("delete_all"); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 2) + .unwrap() + .is_none() + ); + } +} diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index fa250d696..0c8e92629 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -34,6 +34,7 @@ mod event_bridge; pub mod hydration; #[cfg(not(any(test, feature = "bench")))] pub(crate) mod hydration; +pub mod identity_key_store; mod kv; #[cfg(test)] pub(crate) mod leak_test_support; @@ -68,6 +69,7 @@ pub use secret_prompt::{ NullSecretPrompt, RememberPolicy, SecretPrompt, SecretPromptCancelled, SecretPromptReply, SecretPromptRequest, SecretPromptRetry, SecretScope, }; +pub use identity_key_store::IdentityKeyView; pub use secret_seam::SecretSeam; use coordinator_gate::CoordinatorGate; diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs index 8c09b0735..cc4345a90 100644 --- a/src/wallet_backend/single_key.rs +++ b/src/wallet_backend/single_key.rs @@ -209,35 +209,57 @@ impl<'a> SingleKeyView<'a> { .map_err(|_| TaskError::SingleKeyCryptoFailure)?, ); - let entry = match passphrase.passphrase.as_ref().map(|p| p.as_str()) { - Some(p) if !p.is_empty() => { - if p.chars().count() < MIN_SINGLE_KEY_PASSPHRASE_LEN { - return Err(TaskError::SingleKeyPassphraseTooShort { - min: MIN_SINGLE_KEY_PASSPHRASE_LEN as u32, - }); - } - let pub_bytes = pub_key.inner.serialize().to_vec(); - SingleKeyEntry::protected(&raw, p, passphrase.hint.clone(), pub_bytes)? - } - _ => SingleKeyEntry::unprotected(*raw), - }; - let payload = entry.encode()?; - + let pub_bytes = pub_key.inner.serialize().to_vec(); let label = label_for_address(&address_str); - let bytes = SecretBytes::from_slice(&payload); - self.secret_store - .set(&single_key_namespace_id(), &label, &bytes) - .map_err(|source| TaskError::SecretStore { - source: Box::new(source), - })?; + + // Unprotected keys store the RAW 32 bytes via the seam under the + // existing label — no `SingleKeyEntry` framing. Protected keys keep the + // legacy AES-GCM `SingleKeyEntry` at import and migrate to raw lazily on + // the next unlock through the chokepoint. The locked-render pubkey lives + // in the `ImportedKey` sidecar either way. + let (has_passphrase, passphrase_hint) = + match passphrase.passphrase.as_ref().map(|p| p.as_str()) { + Some(p) if !p.is_empty() => { + if p.chars().count() < MIN_SINGLE_KEY_PASSPHRASE_LEN { + return Err(TaskError::SingleKeyPassphraseTooShort { + min: MIN_SINGLE_KEY_PASSPHRASE_LEN as u32, + }); + } + let entry = + SingleKeyEntry::protected(&raw, p, passphrase.hint.clone(), pub_bytes.clone())?; + let payload = entry.encode()?; + self.secret_store + .set( + &single_key_namespace_id(), + &label, + &SecretBytes::from_slice(&payload), + ) + .map_err(|source| TaskError::SecretStore { + source: Box::new(source), + })?; + (true, passphrase.hint.clone()) + } + _ => { + self.secret_store + .set( + &single_key_namespace_id(), + &label, + &SecretBytes::from_slice(&*raw), + ) + .map_err(|source| TaskError::SecretStore { + source: Box::new(source), + })?; + (false, None) + } + }; let imported = ImportedKey { address: address_str.clone(), alias, network: self.network, - has_passphrase: entry.has_passphrase, - passphrase_hint: entry.passphrase_hint.clone(), - public_key_bytes: pub_key.inner.serialize().to_vec(), + has_passphrase, + passphrase_hint, + public_key_bytes: pub_bytes, }; if let Some(kv) = self.app_kv { @@ -1527,4 +1549,44 @@ mod tests { view.sign_with(&address, &[0x11u8; 32]) .expect("legacy sign without passphrase"); } + + /// TS-RT-02 / TS-EAGER-02 (import half) — an unprotected import writes the + /// RAW 32 bytes under the canonical label (no `SingleKeyEntry` framing), + /// the sidecar carries the public key for locked render, and the key signs. + #[test] + fn unprotected_import_writes_raw_32_bytes_not_framed() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + let imported = view.import_wif(known_wif(), Some("raw".into())).expect("import"); + assert!(!imported.has_passphrase); + assert!( + !imported.public_key_bytes.is_empty(), + "sidecar carries the locked-render public key" + ); + + // Vault payload is exactly the raw 32 bytes — no version-tag framing. + let label = label_for_address(&imported.address); + let raw = store + .get(&single_key_namespace_id(), &label) + .expect("get") + .expect("present"); + assert_eq!(raw.expose_secret().len(), 32, "raw, not a versioned envelope"); + let priv_key = PrivateKey::from_wif(known_wif()).unwrap(); + assert_eq!(raw.expose_secret(), &priv_key.inner[..]); + + // Signs with no passphrase. + view.sign_with(&imported.address, &[0x42u8; 32]) + .expect("raw key signs"); + } } diff --git a/src/wallet_backend/wallet_seed_store.rs b/src/wallet_backend/wallet_seed_store.rs index 86bc0d14e..c572ed7f9 100644 --- a/src/wallet_backend/wallet_seed_store.rs +++ b/src/wallet_backend/wallet_seed_store.rs @@ -29,10 +29,13 @@ use std::sync::Arc; use platform_wallet_storage::secrets::{ SecretBytes, SecretStore, SecretStoreError, WalletId as SecretWalletId, }; +use zeroize::Zeroizing; use crate::backend_task::error::TaskError; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::seed_envelope::{STORED_SEED_ENVELOPE_VERSION, StoredSeedEnvelope}; +use crate::wallet_backend::secret_access::SEED_RAW_LABEL; +use crate::wallet_backend::secret_seam::SecretSeam; /// Label under which the bincode-encoded envelope is stored. Versioned /// so a future shape change (e.g. an additional field that breaks @@ -136,6 +139,54 @@ impl<'a> WalletSeedView<'a> { .delete(&scope_for(seed_hash), ENVELOPE_LABEL) .map_err(map_err) } + + /// Retained decode-only legacy reader: read the `envelope.v1` row. Alias + /// for [`Self::get`] under the migration-reader name — the loader and the + /// chokepoint reach for it explicitly when the raw seed is absent. + pub fn legacy_envelope_get( + &self, + seed_hash: &WalletSeedHash, + ) -> Result, TaskError> { + self.get(seed_hash) + } + + /// Store the RAW 64-byte BIP-39 seed under `seed.raw.v1` via the seam. + /// No DET-side encryption — the seam writes the bytes verbatim. The + /// non-secret metadata (`uses_password`, hint, xpub) lives in `WalletMeta`. + pub fn set_raw(&self, seed_hash: &WalletSeedHash, seed: &[u8; 64]) -> Result<(), TaskError> { + SecretSeam::new(self.secret_store).put_secret( + &scope_for(seed_hash), + SEED_RAW_LABEL, + &SecretBytes::from_slice(seed), + ) + } + + /// Read the RAW 64-byte seed under `seed.raw.v1`, or `None` if it has not + /// been migrated to the raw label yet. + pub fn get_raw( + &self, + seed_hash: &WalletSeedHash, + ) -> Result>, TaskError> { + let Some(bytes) = + SecretSeam::new(self.secret_store).get_secret(&scope_for(seed_hash), SEED_RAW_LABEL)? + else { + return Ok(None); + }; + let seed: [u8; 64] = bytes.expose_secret().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::wallet_seed_store", + blob_len = bytes.expose_secret().len(), + "Raw seam seed has wrong length", + ); + map_err(SecretStoreError::MalformedVault) + })?; + Ok(Some(Zeroizing::new(seed))) + } + + /// Idempotent delete of the raw `seed.raw.v1` row. + pub fn delete_raw(&self, seed_hash: &WalletSeedHash) -> Result<(), TaskError> { + SecretSeam::new(self.secret_store).delete_secret(&scope_for(seed_hash), SEED_RAW_LABEL) + } } /// Reuse the 32-byte `WalletSeedHash` as the upstream `WalletId` @@ -345,4 +396,33 @@ mod tests { assert_eq!(view.get(&a).unwrap().unwrap(), envelope_a); assert_eq!(view.get(&b).unwrap().unwrap(), envelope_b); } + + /// The raw seam path round-trips the exact 64-byte seed and is independent + /// of the legacy `envelope.v1` row (distinct labels). `get_raw` on a hash + /// with only a legacy envelope returns `None`. + #[test] + fn raw_seed_round_trips_independent_of_legacy() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let seed_hash: WalletSeedHash = [0xB1; 32]; + let mut seed = [0u8; 64]; + for (i, b) in seed.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(9).wrapping_add(1); + } + + view.set_raw(&seed_hash, &seed).expect("set_raw"); + assert_eq!(*view.get_raw(&seed_hash).unwrap().unwrap(), seed); + // The legacy reader sees nothing under this hash. + assert!(view.legacy_envelope_get(&seed_hash).unwrap().is_none()); + + view.delete_raw(&seed_hash).expect("delete_raw"); + assert!(view.get_raw(&seed_hash).unwrap().is_none()); + + // A legacy-only hash returns None from the raw reader. + let legacy_only: WalletSeedHash = [0xB2; 32]; + view.set(&legacy_only, &sample_non_password_envelope()) + .unwrap(); + assert!(view.get_raw(&legacy_only).unwrap().is_none()); + } } From a6c11a7993d7a840e4b542f3311cc501e5f0a68a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:37:49 +0200 Subject: [PATCH 12/24] feat: crash-safe dual-format migration + InVault resolver + vault delete (T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the part that actually moves secrets. Funds-safety ordering throughout. Resolver (mod.rs): resolve_private_key_bytes gains the InVault route — keyed by is_in_vault/public_key_for, it fetches the raw bytes per-use via with_secret(IdentityKey{...}) (prompt-free). No chokepoint wired ⇒ fail closed (WalletLocked); bytes never resident. EAGER migration on load (dialog-free): - Identity keys (identity_db::migrate_identity_keys_to_vault, run per identity in load_identities_filtered): take_plaintext_for_vault → IdentityKeyView store_all (vault FIRST) → rewrite the QI blob with InVault. Vault-write failure restores the resident plaintext for this session and defers; a blob-rewrite failure is re-detected and retried next load. Idempotent. - No-password HD seeds (hydration::reconstruct_wallet): raw seam wins (precedence raw > legacy); a no-password legacy envelope is re-stored raw (set_raw, vault FIRST) then deleted. reconstruct_from_envelope extracted so the raw and legacy paths share the xpub-decode + build tail. LAZY migration on unlock (one prompt, the unlock the user already does): promote_and_maybe_migrate_hd_seed re-stores the just-decrypted legacy seed raw (set_raw before delete) inside the borrowed Zeroizing scope and reports migrated=true; handle_wallet_unlocked then flips WalletMeta.uses_password=false and shows the one-time disclosure (T8 Copy A/D). Delete: forget_wallet_local_state now deletes BOTH the raw seed and the legacy envelope (a wallet may be in either form) — closes a wipe gap where a migrated no-password seed would survive removal. identity_db.clear_identity_vault_keys drains an identity's raw vault keys on single-delete + devnet sweep. Loud, never silent: a seed in neither form ⇒ TaskError::SecretSeamMissing (was WalletNotFound) on both scope_has_passphrase and decrypt_jit. Tests: TS-EAGER-01/04 (no-pw seed migrates + idempotent), TS-CRASH-01 read (raw wins, legacy cleaned), TS-MISS-01 (SecretSeamMissing loud). Updated 5 wallet_lifecycle removal/clear tests to assert the raw seed (the new at-rest form) in BOTH precondition and post-delete. wallet_lifecycle 38, hydration 10, identity_db 16, encrypted_key_storage 4 — all green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/context/identity_db.rs | 111 ++++++++++++++++++++ src/context/wallet_lifecycle.rs | 144 ++++++++++++++++++------- src/model/qualified_identity/mod.rs | 30 +++++- src/wallet_backend/hydration.rs | 157 +++++++++++++++++++++++++++- src/wallet_backend/mod.rs | 12 ++- src/wallet_backend/secret_access.rs | 68 +++++++++++- 6 files changed, 478 insertions(+), 44 deletions(-) diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index c08b05143..0392e38d9 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -500,6 +500,7 @@ impl AppContext { qi.associated_wallets = wallets.clone(); qi.secret_access = self.wallet_backend().ok().map(|b| b.secret_access()); qi.top_ups = BTreeMap::new(); + self.migrate_identity_keys_to_vault(&kv, &id, &mut qi); out.push(qi); } Ok(out) @@ -618,10 +619,119 @@ impl AppContext { ) -> std::result::Result<(), TaskError> { let kv = self.identity_kv()?; let id = identifier.to_buffer(); + self.clear_identity_vault_keys(&kv, &id); purge_identity_scope(&kv, &id)?; index_remove_identity(&kv, &id) } + /// EAGER identity-key migration (dialog-free): move any plaintext + /// `Clear`/`AlwaysClear` identity keys into the vault as raw bytes and + /// rewrite the blob with `InVault` placeholders so the keys are never + /// resident. + /// + /// Crash-safe ordering: vault `store_all` FIRST, then blob rewrite. If the + /// vault write fails the blob is left untouched (the in-memory `qi` is + /// restored to its resident plaintext for this session) and the next load + /// retries — keys are never lost. Idempotent: a blob already all-`InVault` + /// has nothing to take and is skipped. Best-effort: a blob-rewrite failure + /// is logged; the next load re-detects the plaintext and retries. + fn migrate_identity_keys_to_vault( + &self, + kv: &crate::wallet_backend::DetKv, + id: &[u8; 32], + qi: &mut QualifiedIdentity, + ) { + let before = qi.private_keys.clone(); + let taken = qi.private_keys.take_plaintext_for_vault(); + if taken.is_empty() { + return; + } + let view = crate::wallet_backend::IdentityKeyView::new(&self.secret_store, *id); + if let Err(e) = view.store_all(&taken) { + // Vault-first failed: restore the resident plaintext so this + // session can still sign, and leave the blob for the next retry. + qi.private_keys = before; + tracing::warn!( + target = "context::identity_db", + identity = %hex::encode(id), + error = ?e, + "Identity-key vault migration deferred (vault write failed)", + ); + return; + } + // Vault holds the raw bytes; rewrite the blob with the InVault + // placeholders. A failure here is recoverable — the legacy plaintext + // blob plus the (now redundant) raw vault entries are re-detected next + // load and the migration re-runs idempotently. + if let Err(e) = self.persist_identity_blob(kv, id, qi) { + tracing::warn!( + target = "context::identity_db", + identity = %hex::encode(id), + error = ?e, + "Identity-key blob rewrite deferred after vault migration", + ); + } else { + tracing::info!( + target = "context::identity_db", + identity = %hex::encode(id), + migrated = taken.len(), + "Migrated identity keys to the secret vault", + ); + } + } + + /// Re-persist `qi`'s blob in place, preserving the stored wallet + /// association and status. Used by the eager identity-key migration. + fn persist_identity_blob( + &self, + kv: &crate::wallet_backend::DetKv, + id: &[u8; 32], + qi: &QualifiedIdentity, + ) -> std::result::Result<(), TaskError> { + let scope = DetScope::Identity(id); + let existing: Option = kv + .get(scope, IDENTITY_KEY) + .map_err(|source| TaskError::IdentityStorage { source })?; + let (wallet_hash, wallet_index, status) = existing + .as_ref() + .map(|s| (s.wallet_hash, s.wallet_index, s.status)) + .unwrap_or((None, None, qi.status.as_u8())); + let stored = StoredQualifiedIdentity { + qi_bytes: qi.to_bytes(), + status, + identity_type: format!("{:?}", qi.identity_type), + wallet_hash, + wallet_index, + }; + kv.put(scope, IDENTITY_KEY, &stored) + .map_err(|source| TaskError::IdentityStorage { source }) + } + + /// Delete every identity-key raw secret for `id` from the vault. Best + /// effort: a decode/read failure is logged and skipped so identity removal + /// never wedges on an unreadable blob — leaving a stale vault entry is + /// preferable to blocking the delete, and the entry is unreachable once the + /// blob is gone. Idempotent (deleting an absent label is `Ok`). + fn clear_identity_vault_keys(&self, kv: &crate::wallet_backend::DetKv, id: &[u8; 32]) { + let Ok(Some(stored)) = + kv.get::(DetScope::Identity(id), IDENTITY_KEY) + else { + return; + }; + let Ok(qi) = QualifiedIdentity::from_bytes(&stored.qi_bytes) else { + return; + }; + let view = crate::wallet_backend::IdentityKeyView::new(&self.secret_store, *id); + if let Err(e) = view.delete_all(qi.private_keys.keys_set()) { + tracing::warn!( + target = "context::identity_db", + identity = %hex::encode(id), + error = ?e, + "Failed to clear some identity vault keys on delete; continuing", + ); + } + } + /// Devnet-only sweep: drop every locally-stored identity for the /// current network. Matches the pre-C7 /// `delete_all_local_qualified_identities_in_devnet` guard — no-op on @@ -635,6 +745,7 @@ impl AppContext { let kv = self.identity_kv()?; let ids = load_identity_index(&kv)?; for id in &ids { + self.clear_identity_vault_keys(&kv, id); purge_identity_scope(&kv, id)?; } kv.delete(DetScope::Global, IDENTITY_INDEX_KEY) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index c0c8b4b21..2529003b5 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -17,6 +17,11 @@ use std::sync::{Arc, RwLock}; /// window so the common identity-load path serves entirely from cache. const AUTH_PUBKEY_WARM_KEY_COUNT: u32 = 12; +/// Copy D — the shared, opt-in technical detail attached to the one-time +/// at-rest disclosure notice (jargon-free per the persona spec). Surfaced via +/// `with_details`, so it lives in the collapsible panel and the log. +const INTERIM_AT_REST_DETAILS: &str = "This wallet's secrets are now stored in a shared protected location on this device, guarded by your computer's account and file permissions rather than by your wallet password. This is a temporary step while a stronger, built-in protection is being finished. Your keys never leave this device. To keep this wallet extra safe in the meantime, make sure your computer account is password-protected and not shared."; + /// The upstream `dash-spv` `DiskStorageManager` chain-cache entries under the /// per-network SPV directory. Each is a subfolder except `peers.dat`. The /// wallet/shielded SQLite sidecars in the same directory are deliberately @@ -944,8 +949,12 @@ impl AppContext { wallet: &Arc>, passphrase: Option<&str>, ) { - let (seed_hash, uses_password) = match wallet.read() { - Ok(guard) => (guard.seed_hash(), guard.uses_password), + let (seed_hash, uses_password, wallet_alias) = match wallet.read() { + Ok(guard) => ( + guard.seed_hash(), + guard.uses_password, + guard.alias.clone(), + ), Err(_) => return, }; @@ -964,15 +973,21 @@ impl AppContext { return; }; let secret = platform_wallet_storage::secrets::SecretString::new(passphrase); - match backend.secret_access().promote_hd_seed_with_passphrase( + match backend.secret_access().promote_and_maybe_migrate_hd_seed( &seed_hash, Some(&secret), crate::wallet_backend::RememberPolicy::UntilAppClose, ) { - Ok(()) => tracing::trace!( - wallet = %hex::encode(seed_hash), - "Verified-open seed promoted to the session cache on unlock" - ), + Ok(migrated) => { + tracing::trace!( + wallet = %hex::encode(seed_hash), + migrated, + "Verified-open seed promoted to the session cache on unlock" + ); + if migrated { + self.finish_lazy_seed_migration(&seed_hash, wallet_alias.as_deref()); + } + } Err(error) => tracing::debug!( wallet = %hex::encode(seed_hash), %error, @@ -1000,6 +1015,40 @@ impl AppContext { self.queue_unlocked_wallet_identity_discovery(wallet); } + /// Finish a LAZY HD-seed migration after the unlock decrypt + raw re-store: + /// flip `WalletMeta.uses_password` to `false` (the password no longer gates + /// the at-rest secret) and show the one-time per-wallet disclosure notice. + /// + /// The flip is what makes the notice fire exactly once: after it, + /// `handle_wallet_unlocked`'s `uses_password` gate returns early on every + /// future unlock, so this never re-runs for the wallet. + fn finish_lazy_seed_migration(&self, seed_hash: &WalletSeedHash, alias: Option<&str>) { + use crate::ui::MessageType; + use crate::ui::components::message_banner::MessageBanner; + + let view = WalletMetaView::new(&self.app_kv); + if let Some(mut meta) = view.get(self.network, seed_hash) { + meta.uses_password = false; + if let Err(error) = view.set(self.network, seed_hash, &meta) { + tracing::warn!( + wallet = %hex::encode(seed_hash), + %error, + "Could not clear the migrated wallet's password flag", + ); + } + } + + // Copy A (wallet) — Warning so it does not auto-dismiss before read. + // Distinct text from the imported-key notice so `set_global`'s dedup + // does not collapse them when both migrate in one session. + let wallet = alias.filter(|a| !a.is_empty()).unwrap_or("Your wallet"); + let message = format!( + "\"{wallet}\" no longer needs its password to open. Your wallet stays on this device, protected by your computer's account. Full password protection will return in a future update." + ); + MessageBanner::set_global(self.egui_ctx(), &message, MessageType::Warning) + .with_details(INTERIM_AT_REST_DETAILS); + } + /// Spawn the unlock-triggered JIT bootstrap/registration for a wallet whose /// seed was just promoted to the session cache by [`Self::handle_wallet_unlocked`]. /// @@ -2249,16 +2298,26 @@ mod tests { .register_wallet(wallet, &seed, WalletOrigin::Imported) .expect("register wallet before the backend is wired"); - let envelope = WalletSeedView::new(&ctx.secret_store()) - .get(&seed_hash) + // A no-password wallet persists the RAW seed via the seam (no legacy + // envelope), and the xpub rides in the WalletMeta sidecar. + let raw = WalletSeedView::new(&ctx.secret_store()) + .get_raw(&seed_hash) .expect("vault read must not error") - .expect("the seed envelope must be persisted at register time, even unwired"); + .expect("the raw seed must be persisted at register time, even unwired"); + assert_eq!(&*raw, &seed, "persisted raw seed must equal the wallet seed"); assert!( - !envelope.uses_password, - "the persisted envelope must carry the no-password flag for the W2 fast-path" + WalletSeedView::new(&ctx.secret_store()) + .legacy_envelope_get(&seed_hash) + .unwrap() + .is_none(), + "no legacy envelope is written for a no-password wallet" ); + let meta = WalletMetaView::new(&ctx.app_kv()) + .get(Network::Testnet, &seed_hash) + .expect("wallet-meta sidecar persisted at register time"); + assert!(!meta.uses_password, "no-password wallet meta flag"); assert_eq!( - envelope.xpub_encoded, + meta.xpub_encoded, ctx.wallets .read() .unwrap() @@ -2390,24 +2449,29 @@ mod tests { let backend = ctx.wallet_backend().expect("backend wired"); - // Precondition: the seed envelope is present. + // Precondition: the raw seed is present (no-password wallet stores raw). assert!( WalletSeedView::new(&ctx.secret_store()) - .get(&seed_hash) + .get_raw(&seed_hash) .expect("vault read") .is_some(), - "precondition: the seed envelope must exist before removal" + "precondition: the raw seed must exist before removal" ); ctx.remove_wallet(&seed_hash).expect("remove wallet"); - // The encrypted seed envelope (the JIT decrypt source) is gone. + // The seed (the JIT decrypt source) is gone in BOTH forms. + let store = ctx.secret_store(); + let view = WalletSeedView::new(&store); assert!( - WalletSeedView::new(&ctx.secret_store()) - .get(&seed_hash) - .expect("vault read after removal") + view.get_raw(&seed_hash).expect("raw read after removal").is_none(), + "the raw seed must be deleted from the vault on removal" + ); + assert!( + view.legacy_envelope_get(&seed_hash) + .expect("legacy read after removal") .is_none(), - "the seed envelope must be deleted from the vault on removal" + "any legacy envelope must also be gone on removal" ); backend.shutdown().await; @@ -2501,13 +2565,13 @@ mod tests { let backend = ctx.wallet_backend().expect("backend wired"); - // Precondition: the seed envelope exists. + // Precondition: the raw seed exists. assert!( WalletSeedView::new(&ctx.secret_store()) - .get(&seed_hash) + .get_raw(&seed_hash) .expect("vault read") .is_some(), - "precondition: the seed envelope must exist before removal" + "precondition: the raw seed must exist before removal" ); // Pre-fix this returned `Err(no such table: wallet_addresses)` and the @@ -2515,12 +2579,17 @@ mod tests { ctx.remove_wallet(&seed_hash) .expect("remove_wallet must succeed on a fresh install"); + let store = ctx.secret_store(); + let view = WalletSeedView::new(&store); assert!( - WalletSeedView::new(&ctx.secret_store()) - .get(&seed_hash) - .expect("vault read after removal") + view.get_raw(&seed_hash).expect("raw read after removal").is_none(), + "the raw seed must be deleted from the vault on a fresh install" + ); + assert!( + view.legacy_envelope_get(&seed_hash) + .expect("legacy read after removal") .is_none(), - "the seed envelope must be deleted from the vault on a fresh install" + "no legacy envelope must survive removal on a fresh install" ); backend.shutdown().await; @@ -2556,28 +2625,33 @@ mod tests { ); assert!( WalletSeedView::new(&ctx.secret_store()) - .get(&seed_hash) + .get_raw(&seed_hash) .expect("vault read") .is_some(), - "precondition: seed envelope must exist before clear" + "precondition: raw seed must exist before clear" ); ctx.clear_network_database() .expect("clear_network_database should succeed"); - // The wallet must not rehydrate: its meta and encrypted seed are gone. + // The wallet must not rehydrate: its meta and seed (both forms) are gone. assert!( WalletMetaView::new(&ctx.app_kv()) .get(Network::Testnet, &seed_hash) .is_none(), "wallet-meta sidecar must be empty after clear (no rehydration)" ); + let store = ctx.secret_store(); + let view = WalletSeedView::new(&store); assert!( - WalletSeedView::new(&ctx.secret_store()) - .get(&seed_hash) - .expect("vault read after clear") + view.get_raw(&seed_hash).expect("raw read after clear").is_none(), + "raw seed must be deleted from the vault after clear" + ); + assert!( + view.legacy_envelope_get(&seed_hash) + .expect("legacy read after clear") .is_none(), - "seed envelope must be deleted from the vault after clear" + "no legacy envelope must survive clear" ); assert!( ctx.wallets.read().unwrap().is_empty(), diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 2ee284e2c..5dec8abec 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -31,6 +31,7 @@ use egui::Color32; use std::collections::{BTreeMap, HashSet}; use std::fmt::{Display, Formatter}; use std::sync::{Arc, RwLock}; +use zeroize::Zeroizing; #[derive(Debug, Encode, Decode, PartialEq, Clone, Copy)] pub enum IdentityType { @@ -521,7 +522,34 @@ impl QualifiedIdentity { target: PrivateKeyTarget, key_id: KeyID, ) -> Result, TaskError> { - let resolve_key = (target, key_id); + let resolve_key = (target.clone(), key_id); + + // Vault-backed identity key: fetch the raw bytes per-use through the + // chokepoint (unprotected fast-path, no prompt). Requires the + // chokepoint to be wired; without it the key cannot be resolved (the + // bytes are not resident), so fail closed. + if self.private_keys.is_in_vault(&resolve_key) { + let Some(secret_access) = self.secret_access.as_ref() else { + return Err(TaskError::WalletLocked); + }; + let Some(public_key) = self.private_keys.public_key_for(&resolve_key).cloned() else { + return Ok(None); + }; + let scope = crate::wallet_backend::SecretScope::IdentityKey { + identity_id: self.identity.id().to_buffer(), + target, + key_id, + }; + return secret_access + .with_secret(&scope, move |plaintext| { + let key = plaintext + .expose_identity_key() + .ok_or(TaskError::IdentityKeyMissing)?; + Ok(Some((public_key, Zeroizing::new(*key)))) + }) + .await; + } + match ( self.secret_access.as_ref(), self.private_keys.wallet_seed_hash_for(&resolve_key), diff --git a/src/wallet_backend/hydration.rs b/src/wallet_backend/hydration.rs index 3a16a579c..afc952da7 100644 --- a/src/wallet_backend/hydration.rs +++ b/src/wallet_backend/hydration.rs @@ -92,6 +92,21 @@ fn reconstruct_wallet( seed_hash: &WalletSeedHash, meta: &WalletMeta, ) -> Result, TaskError> { + // Raw seam value wins (precedence raw > legacy). A migrated no-password + // wallet has no envelope — its seed rides raw under `seed.raw.v1` and its + // non-secret metadata (xpub) lives in `WalletMeta`. + if let Some(raw) = seed_view.get_raw(seed_hash)? { + let envelope = StoredSeedEnvelope { + encrypted_seed: raw.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: meta.password_hint.clone(), + uses_password: false, + xpub_encoded: meta.xpub_encoded.clone(), + }; + return reconstruct_from_envelope(seed_hash, envelope, meta); + } + let envelope = match seed_view.get(seed_hash)? { Some(e) => e, None => { @@ -104,9 +119,45 @@ fn reconstruct_wallet( } }; - // Prefer the envelope's xpub (written by T-W-00.5-v2) over the meta - // one. The meta copy was carried for the cold-boot picker before the - // envelope path was wired; in practice they are written together. + // EAGER migration (dialog-free): a no-password legacy envelope holds the + // raw seed verbatim. Re-store it raw (vault-FIRST) then drop the legacy + // envelope so the at-rest plaintext-equivalent form is gone. Crash-safe and + // idempotent — `set_raw` upserts, and a crash before `delete` leaves both + // forms with raw preferred next load. A password envelope is left for the + // lazy unlock migration. + if !envelope.uses_password + && envelope.encrypted_seed.len() == EXPECTED_SEED_LEN as usize + && let Ok(seed) = <[u8; 64]>::try_from(envelope.encrypted_seed.as_slice()) + { + if let Err(e) = seed_view.set_raw(seed_hash, &seed) { + tracing::warn!( + target = "wallet_backend::hydration", + seed_hash = %hex::encode(seed_hash), + error = ?e, + "Eager no-password seed migration deferred (raw write failed)", + ); + } else if let Err(e) = seed_view.delete(seed_hash) { + tracing::warn!( + target = "wallet_backend::hydration", + seed_hash = %hex::encode(seed_hash), + error = ?e, + "Eager seed migration left a redundant legacy envelope (delete failed)", + ); + } + } + + reconstruct_from_envelope(seed_hash, envelope, meta) +} + +/// Decode the master xpub (envelope copy preferred, `WalletMeta` fallback) and +/// assemble the `Wallet`. Shared by the raw-seam and legacy-envelope paths in +/// [`reconstruct_wallet`]. `Ok(None)` (skip + log) when the xpub is absent or +/// undecodable. +fn reconstruct_from_envelope( + seed_hash: &WalletSeedHash, + envelope: StoredSeedEnvelope, + meta: &WalletMeta, +) -> Result, TaskError> { let xpub_bytes: &[u8] = if !envelope.xpub_encoded.is_empty() { &envelope.xpub_encoded } else { @@ -397,6 +448,106 @@ mod tests { assert_eq!(wallet.seed_hash(), hash); } + /// TS-EAGER-01 / TS-EAGER-04 — a no-password legacy envelope is eagerly + /// migrated on load: the raw `seed.raw.v1` is written, the legacy + /// `envelope.v1` is deleted, and a reload reads via the raw seam. Running + /// the load twice is idempotent (second pass already-raw, legacy gone). + #[test] + fn ts_eager_01_no_password_seed_migrates_on_load() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = fresh_secret_store(dir.path()); + let view = WalletSeedView::new(&store); + + let seed = [0x5Au8; 64]; + let network = Network::Testnet; + let xpub = xpub_bytes_for(seed, network); + let hash = seed_hash_for(seed); + view.set( + &hash, + &StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }, + ) + .expect("seed legacy envelope"); + let meta = WalletMeta { + alias: "eager".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + + // First load migrates. + let wallet = reconstruct_wallet(&view, &hash, &meta) + .expect("no error") + .expect("rebuilt"); + assert!(wallet.is_open()); + // Raw present and equals the seed; legacy gone. + assert_eq!(*view.get_raw(&hash).unwrap().unwrap(), seed); + assert!( + view.legacy_envelope_get(&hash).unwrap().is_none(), + "legacy envelope deleted after eager migration" + ); + + // Second load is idempotent — reads via the raw seam, no error, + // legacy still absent, raw byte-identical. + let wallet2 = reconstruct_wallet(&view, &hash, &meta) + .expect("no error") + .expect("rebuilt again"); + assert!(wallet2.is_open()); + assert_eq!(*view.get_raw(&hash).unwrap().unwrap(), seed); + assert!(view.legacy_envelope_get(&hash).unwrap().is_none()); + } + + /// TS-CRASH-01 (read half) — the legal mid-migration state (raw present + /// AND legacy still present) loads from the RAW value; the leftover legacy + /// is cleaned up. No key loss, no error. + #[test] + fn ts_crash_01_raw_wins_and_legacy_is_cleaned() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = fresh_secret_store(dir.path()); + let view = WalletSeedView::new(&store); + + let seed = [0x6Bu8; 64]; + let network = Network::Testnet; + let xpub = xpub_bytes_for(seed, network); + let hash = seed_hash_for(seed); + // Both forms present (crash after raw write, before legacy delete). + view.set_raw(&hash, &seed).expect("raw"); + view.set( + &hash, + &StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }, + ) + .expect("legacy too"); + let meta = WalletMeta { + alias: "midmig".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + + let wallet = reconstruct_wallet(&view, &hash, &meta) + .expect("no error") + .expect("rebuilt"); + assert!(wallet.is_open()); + assert_eq!(*view.get_raw(&hash).unwrap().unwrap(), seed); + } + /// Orphan path — a `WalletMeta` entry whose envelope is missing is /// returned as `Ok(None)` from `reconstruct_wallet` so the picker /// can keep listing the survivors. diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 0c8e92629..73949f51a 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -815,7 +815,17 @@ impl WalletBackend { seed_hash: &WalletSeedHash, wallet_id: Option, ) -> Result<(), TaskError> { - // Encrypted seed-envelope vault (the JIT decrypt source). + // Seed vault — delete BOTH the raw `seed.raw.v1` (the current form) and + // the legacy `envelope.v1`. Idempotent on both; a wallet may be in + // either form (raw post-migration, legacy pre-migration), so removal + // must clear whichever is present to leave no recoverable seed. + if let Err(e) = self.wallet_seeds().delete_raw(seed_hash) { + tracing::warn!( + wallet = %hex::encode(seed_hash), + error = ?e, + "Failed to delete raw seed from vault" + ); + } if let Err(e) = self.wallet_seeds().delete(seed_hash) { tracing::warn!( wallet = %hex::encode(seed_hash), diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index 8e97512d5..f9e7dd730 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -414,12 +414,50 @@ impl SecretAccess { passphrase: Option<&SecretString>, policy: RememberPolicy, ) -> Result<(), TaskError> { + self.promote_and_maybe_migrate_hd_seed(seed_hash, passphrase, policy) + .map(|_migrated| ()) + } + + /// As [`Self::promote_hd_seed_with_passphrase`], but reports whether a + /// LAZY raw-seam migration was performed. + /// + /// When the seed is still in a legacy `envelope.v1` (no raw label), this + /// re-stores the decrypted 64-byte seed raw via the seam (vault-FIRST) and + /// deletes the legacy envelope — all inside the borrowed `Zeroizing` scope, + /// so the plaintext is never copied out. Returns `Ok(true)` when that + /// migration ran (the caller flips `WalletMeta.uses_password=false`), or + /// `Ok(false)` when the seed was already raw (nothing to migrate). + /// + /// Crash-safe: `set_raw` (upsert) precedes `delete`; a crash between leaves + /// both forms present and the loader prefers raw. Idempotent. + pub fn promote_and_maybe_migrate_hd_seed( + &self, + seed_hash: &WalletSeedHash, + passphrase: Option<&SecretString>, + policy: RememberPolicy, + ) -> Result { let scope = SecretScope::HdSeed { seed_hash: *seed_hash, }; + let already_raw = WalletSeedView::new(&self.inner.secret_store) + .get_raw(seed_hash)? + .is_some(); let plaintext = self.decrypt_jit(&scope, passphrase)?; + + let mut migrated = false; + if !already_raw + && let Plaintext::HdSeed(seed) = &plaintext + { + // The seed came from the legacy envelope. Re-store it raw + // (vault-first), then drop the legacy envelope. + let view = WalletSeedView::new(&self.inner.secret_store); + view.set_raw(seed_hash, &**seed)?; + view.delete(seed_hash)?; + migrated = true; + } + self.maybe_remember(&scope, &plaintext, policy); - Ok(()) + Ok(migrated) } /// Forget the session-cached secret for `scope`, zeroizing it. @@ -531,7 +569,7 @@ impl SecretAccess { return Ok(false); } let view = WalletSeedView::new(&self.inner.secret_store); - let envelope = view.get(seed_hash)?.ok_or(TaskError::WalletNotFound)?; + let envelope = view.get(seed_hash)?.ok_or(TaskError::SecretSeamMissing)?; Ok(envelope.uses_password) } SecretScope::SingleKey { address } => { @@ -579,9 +617,10 @@ impl SecretAccess { })?; return Ok(Plaintext::HdSeed(Zeroizing::new(seed))); } - // Legacy fallback (migration reader). + // Legacy fallback (migration reader). Neither raw nor legacy + // present ⇒ the secret is gone (loud, never a silent miss). let view = WalletSeedView::new(&self.inner.secret_store); - let envelope = view.get(seed_hash)?.ok_or(TaskError::WalletNotFound)?; + let envelope = view.get(seed_hash)?.ok_or(TaskError::SecretSeamMissing)?; let seed = decrypt_hd_seed(&envelope, passphrase)?; Ok(Plaintext::HdSeed(seed)) } @@ -1432,6 +1471,27 @@ mod tests { ); } + /// TS-MISS-01/02 — an HD seed present in NEITHER raw nor legacy form + /// surfaces the loud typed `SecretSeamMissing` (never a silent `Ok(None)` + /// that would drop a key on the floor), distinct from `WalletNotFound`. + #[tokio::test] + async fn ts_miss_01_hd_seed_in_neither_form_is_secret_seam_missing() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let sa = access(store, Arc::new(TestPrompt::never())); + let scope = SecretScope::HdSeed { + seed_hash: [0x7Du8; 32], + }; + let err = sa + .with_secret(&scope, |_pt| Ok(())) + .await + .expect_err("seed gone"); + assert!( + matches!(err, TaskError::SecretSeamMissing), + "expected SecretSeamMissing, got {err:?}" + ); + } + /// A missing identity key surfaces the loud typed `IdentityKeyMissing`, /// never a silent miss. #[tokio::test] From aadf5324158ffd4e033ed9d979d9dfd825561421 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:51:00 +0200 Subject: [PATCH 13/24] feat: key_info_screen JIT identity signing + single-key Copy B disclosure (T8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real JIT for vault-backed identity keys, and the per-key migration notice. Two new WalletTasks + handlers, opening with_secret(IdentityKey{...}): - DeriveIdentityKeyForDisplay → derive_identity_key_for_display: fetches the raw key JIT, returns only the WIF (Secret). - SignMessageWithIdentityKey → sign_message_with_identity_key: signs in the backend, returns only the public Base64 envelope. New result variants IdentityKeyForDisplay / IdentityMessageSigned (identity- flavored — carry identity_id/target/key_id, not a meaningless seed_hash). key_info_screen: the InVault arms are now real — "View Private Key" queues DeriveIdentityKeyForDisplay and renders the returned WIF/hex via the existing render_decrypted_key_grid; "Sign" queues SignMessageWithIdentityKey. The degraded placeholders are gone. display_task_result handles both new results. Single-key protected lazy migration + Copy B: verify_passphrase now re-stores the just-decrypted protected entry raw under the same label (upsert replaces the AES-GCM framing) and clears the persistent has_passphrase flag, returning a migrated bool. verify_single_key_passphrase surfaces the one-time per-key disclosure (Copy B — text DISTINCT from the wallet Copy A so set_global's dedup keeps both) on migration. decrypt_jit's sign path also lazy-migrates (migrate_single_key_to_raw + in-memory flag flip) — idempotent defense-in-depth. SingleKeyView::clear_passphrase_flag persists the flip to the sidecar. Tests: TS-LAZY-03 — protected single key migrates via the chokepoint, the vault holds raw 32 bytes after, and a second resolve under a never-prompt host is prompt-free with the WIF-plaintext bytes. secret_access 24 green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/backend_task/mod.rs | 37 ++++++++ .../wallet/derive_identity_key_for_display.rs | 56 ++++++++++++ src/backend_task/wallet/mod.rs | 28 +++++- .../wallet/sign_message_with_identity_key.rs | 66 ++++++++++++++ src/context/wallet_lifecycle.rs | 33 ++++++- src/ui/identities/keys/key_info_screen.rs | 86 ++++++++++++++---- src/wallet_backend/secret_access.rs | 88 ++++++++++++++++++- src/wallet_backend/single_key.rs | 55 +++++++++++- 8 files changed, 422 insertions(+), 27 deletions(-) create mode 100644 src/backend_task/wallet/derive_identity_key_for_display.rs create mode 100644 src/backend_task/wallet/sign_message_with_identity_key.rs diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 53357746b..bc26dab65 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -336,6 +336,25 @@ pub enum BackendTaskSuccessResult { /// The Base64-encoded signature (a public artifact, not a secret). signature: String, }, + /// An identity private key derived for on-screen display/export, fetched + /// JIT from the vault (`InVault`). The key bytes never become resident; + /// only the WIF (zeroize-on-drop) crosses to the UI. + IdentityKeyForDisplay { + identity_id: dash_sdk::platform::Identifier, + target: crate::model::qualified_identity::PrivateKeyTarget, + key_id: dash_sdk::dpp::identity::KeyID, + /// The identity private key as a WIF string, zeroize-on-drop. + wif: crate::model::secret::Secret, + }, + /// A message signed with a vault-backed identity key via the JIT + /// chokepoint. Only the public Base64 signature crosses to the UI. + IdentityMessageSigned { + identity_id: dash_sdk::platform::Identifier, + target: crate::model::qualified_identity::PrivateKeyTarget, + key_id: dash_sdk::dpp::identity::KeyID, + /// The Base64-encoded signature (a public artifact, not a secret). + signature: String, + }, // Token operation results (replacing string messages) PausedTokens(FeeResult), @@ -682,6 +701,24 @@ impl AppContext { self.sign_message_with_key(seed_hash, derivation_path, message, key_type) .await } + WalletTask::DeriveIdentityKeyForDisplay { + identity_id, + target, + key_id, + } => { + self.derive_identity_key_for_display(identity_id, target, key_id) + .await + } + WalletTask::SignMessageWithIdentityKey { + identity_id, + target, + key_id, + message, + key_type, + } => { + self.sign_message_with_identity_key(identity_id, target, key_id, message, key_type) + .await + } WalletTask::ListTrackedAssetLocks { seed_hash } => { let locks = self .wallet_backend()? diff --git a/src/backend_task/wallet/derive_identity_key_for_display.rs b/src/backend_task/wallet/derive_identity_key_for_display.rs new file mode 100644 index 000000000..299725cd7 --- /dev/null +++ b/src/backend_task/wallet/derive_identity_key_for_display.rs @@ -0,0 +1,56 @@ +//! Backend task: derive a vault-backed identity key for on-screen display. +//! Fetches the raw key JIT through the secret chokepoint (`InVault` route); +//! only the WIF crosses back to the UI. + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::qualified_identity::PrivateKeyTarget; +use crate::model::secret::Secret; +use dash_sdk::dpp::dashcore::PrivateKey; +use dash_sdk::dpp::dashcore::secp256k1::SecretKey; +use dash_sdk::dpp::identity::KeyID; +use dash_sdk::platform::Identifier; +use std::sync::Arc; + +impl AppContext { + /// Derive an identity private key for on-screen display/export. + /// + /// The raw key is fetched just-in-time from the vault through the chokepoint + /// (`SecretScope::IdentityKey`, prompt-free) and borrowed only inside the + /// closure; it zeroizes when the closure returns. Only the WIF — wrapped in + /// [`Secret`] — crosses back to the UI. + pub(crate) async fn derive_identity_key_for_display( + self: &Arc, + identity_id: Identifier, + target: PrivateKeyTarget, + key_id: KeyID, + ) -> Result { + let network = self.network; + let scope = crate::wallet_backend::SecretScope::IdentityKey { + identity_id: identity_id.to_buffer(), + target: target.clone(), + key_id, + }; + let backend = self.wallet_backend()?; + let wif = backend + .secret_access() + .with_secret(&scope, |plaintext| { + let key = plaintext + .expose_identity_key() + .ok_or(TaskError::IdentityKeyMissing)?; + let secret_key = + SecretKey::from_byte_array(key).map_err(|_| TaskError::IdentityKeyMissing)?; + let private_key = PrivateKey::new(secret_key, network); + Ok(Secret::new(private_key.to_wif())) + }) + .await?; + + Ok(BackendTaskSuccessResult::IdentityKeyForDisplay { + identity_id, + target, + key_id, + wif, + }) + } +} diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index e8677b7ff..2d6a7eac7 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -1,21 +1,25 @@ +mod derive_identity_key_for_display; mod derive_key_for_display; mod fetch_platform_address_balances; mod fund_platform_address_from_asset_lock; mod fund_platform_address_from_wallet_utxos; mod generate_platform_receive_address; mod generate_receive_address; +mod sign_message_with_identity_key; mod sign_message_with_key; mod transfer_platform_credits; mod warm_identity_auth_pubkeys; mod withdraw_from_platform_address; +use crate::model::qualified_identity::PrivateKeyTarget; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::Credits; use dash_sdk::dpp::dashcore::OutPoint; -use dash_sdk::dpp::identity::KeyType; +use dash_sdk::dpp::identity::{KeyID, KeyType}; use dash_sdk::dpp::identity::core_script::CoreScript; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::platform::Identifier; use std::collections::BTreeMap; #[derive(Debug, Clone, PartialEq)] @@ -64,6 +68,28 @@ pub enum WalletTask { /// The key type that determines the signing scheme. key_type: KeyType, }, + /// Derive an identity private key for on-screen display/export. The raw + /// key is fetched just-in-time from the vault through the JIT chokepoint + /// (`InVault` route) and only the WIF (wrapped in `Secret`) crosses back to + /// the UI — the key bytes never become resident. + DeriveIdentityKeyForDisplay { + identity_id: Identifier, + target: PrivateKeyTarget, + key_id: KeyID, + }, + /// Sign a message with a vault-backed identity key. The raw key is fetched + /// just-in-time through the chokepoint, the message signed in the backend, + /// and only the public Base64 signature crosses back — the key never + /// becomes resident. + SignMessageWithIdentityKey { + identity_id: Identifier, + target: PrivateKeyTarget, + key_id: KeyID, + /// The message to sign (the user-entered plaintext, not a secret). + message: String, + /// The key type that determines the signing scheme. + key_type: KeyType, + }, /// Fetch Platform address balances and nonces from Platform for a wallet FetchPlatformAddressBalances { seed_hash: WalletSeedHash, diff --git a/src/backend_task/wallet/sign_message_with_identity_key.rs b/src/backend_task/wallet/sign_message_with_identity_key.rs new file mode 100644 index 000000000..0a7d402ab --- /dev/null +++ b/src/backend_task/wallet/sign_message_with_identity_key.rs @@ -0,0 +1,66 @@ +//! Backend task: sign a message with a vault-backed identity key. +//! Fetches the raw key JIT through the chokepoint (`InVault` route); only the +//! Base64 signature crosses back to the UI. + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::qualified_identity::PrivateKeyTarget; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; +use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; +use dash_sdk::dpp::identity::{KeyID, KeyType}; +use dash_sdk::platform::Identifier; +use std::sync::Arc; + +impl AppContext { + /// Sign a message with a vault-backed identity key. + /// + /// The raw key is fetched just-in-time through the chokepoint and borrowed + /// only for the single sign inside the closure; it zeroizes on return. Only + /// the public Base64 signature crosses back to the UI. Identity keys are + /// compressed by convention, so the recoverable envelope uses `compressed`. + pub(crate) async fn sign_message_with_identity_key( + self: &Arc, + identity_id: Identifier, + target: PrivateKeyTarget, + key_id: KeyID, + message: String, + key_type: KeyType, + ) -> Result { + // Reject non-ECDSA before touching the vault. + if !matches!(key_type, KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160) { + return Err(TaskError::WalletMessageSignUnsupportedKeyType); + } + + let scope = crate::wallet_backend::SecretScope::IdentityKey { + identity_id: identity_id.to_buffer(), + target: target.clone(), + key_id, + }; + let backend = self.wallet_backend()?; + let signature = backend + .secret_access() + .with_secret(&scope, |plaintext| { + let key = plaintext + .expose_identity_key() + .ok_or(TaskError::IdentityKeyMissing)?; + let secret_key = SecretKey::from_byte_array(key).map_err(|detail| { + tracing::warn!(error = %detail, "Identity-key sign secret construction failed"); + TaskError::WalletMessageSigningFailed + })?; + let secp = Secp256k1::new(); + let digest = Message::from_digest(*signed_msg_hash(message.as_str()).as_byte_array()); + let recoverable = secp.sign_ecdsa_recoverable(&digest, &secret_key); + Ok(MessageSignature::new(recoverable, true).to_base64()) + }) + .await?; + + Ok(BackendTaskSuccessResult::IdentityMessageSigned { + identity_id, + target, + key_id, + signature, + }) + } +} diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 2529003b5..218033702 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -256,13 +256,40 @@ impl AppContext { /// confirmation that their passphrase is correct. Returns /// [`TaskError::SingleKeyPassphraseIncorrect`] on a wrong passphrase. pub fn verify_single_key_passphrase( - &self, + self: &Arc, address: &str, passphrase: &str, ) -> Result<(), TaskError> { - self.wallet_backend()? + // The unlock gesture also lazy-migrates a protected entry to raw + // (verify_passphrase re-stores it). On migration, surface the one-time + // per-key disclosure (Copy B). The alias is read BEFORE the flag flip. + let backend = self.wallet_backend()?; + let label = backend .single_key() - .verify_passphrase(address, passphrase) + .list() + .into_iter() + .find(|k| k.address == address) + .and_then(|k| k.alias) + .unwrap_or_else(|| address.to_string()); + let migrated = backend.single_key().verify_passphrase(address, passphrase)?; + if migrated { + self.show_single_key_migration_notice(&label); + } + Ok(()) + } + + /// Show the one-time per-key disclosure (Copy B) after an imported key's + /// vault secret was lazy-migrated to raw. Distinct copy from the wallet + /// notice so `set_global`'s text-dedup does not collapse them. + fn show_single_key_migration_notice(&self, label: &str) { + use crate::ui::MessageType; + use crate::ui::components::message_banner::MessageBanner; + + let message = format!( + "The imported key \"{label}\" no longer needs its passphrase to use. It stays on this device, protected by your computer's account. Full passphrase protection will return in a future update." + ); + MessageBanner::set_global(self.egui_ctx(), &message, MessageType::Warning) + .with_details(INTERIM_AT_REST_DETAILS); } /// Start chain sync against an already-wired wallet backend. diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 8b3e70dec..fde1d1433 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -2,7 +2,7 @@ use crate::app::AppAction; use crate::backend_task::wallet::WalletTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; @@ -28,6 +28,7 @@ use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; use dash_sdk::dpp::dashcore::{Address, PrivateKey, PubkeyHash, ScriptHash}; use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::KeyType::BIP13_SCRIPT_HASH; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; @@ -66,6 +67,12 @@ pub struct KeyInfoScreen { /// end of `ui()` into a `WalletTask::SignMessageWithKey` backend task — the /// seed is fetched just-in-time and only the public signature returns. pending_sign_request: Option, + /// A queued "derive for display" request for a vault-backed (`InVault`) + /// identity key. Drained into `WalletTask::DeriveIdentityKeyForDisplay`. + pending_identity_key_display: bool, + /// A queued "sign message" request for a vault-backed identity key. Drained + /// into `WalletTask::SignMessageWithIdentityKey`. + pending_identity_sign: bool, } impl ScreenLike for KeyInfoScreen { @@ -93,6 +100,23 @@ impl ScreenLike for KeyInfoScreen { BackendTaskSuccessResult::WalletMessageSigned { signature, .. } => { self.signed_message = Some(signature); } + BackendTaskSuccessResult::IdentityKeyForDisplay { wif, .. } => { + match RPCPrivateKey::from_wif(wif.expose_secret()) { + Ok(private_key) => self.decrypted_private_key = Some(private_key), + Err(e) => { + self.key_display_requested = false; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Could not display the private key. Please retry.", + MessageType::Error, + ) + .with_details(e); + } + } + } + BackendTaskSuccessResult::IdentityMessageSigned { signature, .. } => { + self.signed_message = Some(signature); + } _ => {} } } @@ -441,18 +465,21 @@ impl ScreenLike for KeyInfoScreen { } } PrivateKeyData::InVault => { - // The key's bytes live in the secret vault, fetched - // per-use through the seam. The full view / sign - // flow runs through dedicated identity-key - // WalletTasks (T8 follow-up); until those land, the - // key is shown as securely stored. + // Vault-backed identity key: the raw bytes are + // fetched just-in-time by a backend task. The UI + // only ever sees the derived WIF for display. ui.label( - RichText::new( - "This signing key is stored securely on this device.", - ) - .color(text_primary), + RichText::new("This signing key is stored securely on this device.") + .color(text_primary), ); ui.add_space(10.0); + if let Some(private_key) = self.decrypted_private_key { + Self::render_decrypted_key_grid(ui, &private_key); + } else if ui.button("View Private Key").clicked() { + self.pending_identity_key_display = true; + self.key_display_requested = true; + } + self.render_sign_input(ui); } } } else { @@ -548,6 +575,32 @@ impl ScreenLike for KeyInfoScreen { } } + // Vault-backed (InVault) identity-key requests: the raw key is fetched + // JIT in the backend and only the public WIF / signature returns. + let identity_id = self.identity.identity.id(); + let target: PrivateKeyTarget = self.key.purpose().into(); + let key_id = self.key.id(); + if std::mem::take(&mut self.pending_identity_key_display) { + action |= AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::DeriveIdentityKeyForDisplay { + identity_id, + target: target.clone(), + key_id, + }, + )); + } + if std::mem::take(&mut self.pending_identity_sign) { + action |= AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::SignMessageWithIdentityKey { + identity_id, + target, + key_id, + message: self.message_input.clone(), + key_type: self.key.key_type(), + }, + )); + } + action } } @@ -590,6 +643,8 @@ impl KeyInfoScreen { pending_key_display_request: None, key_display_requested: false, pending_sign_request: None, + pending_identity_key_display: false, + pending_identity_sign: false, } } @@ -746,15 +801,10 @@ impl KeyInfoScreen { MessageType::Error, ); } - // Vault-backed identity key: signing routes through a dedicated - // identity-key WalletTask (T8 follow-up). Until that lands, surface - // a calm, actionable message rather than silently doing nothing. + // Vault-backed identity key: signs in the backend via the JIT + // chokepoint (InVault route). Queue the request; `ui()` dispatches it. PrivateKeyData::InVault => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Signing with this securely-stored key is not available yet. Try a different key.", - MessageType::Error, - ); + self.pending_identity_sign = true; } } } diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index f9e7dd730..a0879ede3 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -628,9 +628,17 @@ impl SecretAccess { if let Some(raw) = self.single_key_raw(address)? { return Ok(Plaintext::SingleKey(raw)); } - // Legacy fallback (migration reader). + // Legacy fallback (migration reader). A protected entry was just + // decrypted with the user's passphrase — LAZY-migrate it to raw + // here (the upsert under the SAME label replaces the AES-GCM + // framing with the raw 32 bytes, so no separate delete is + // needed) and flip the in-memory index so the next resolve takes + // the prompt-free fast-path. Idempotent. let entry = self.load_single_key_entry(address)?; let raw = entry.decrypt(passphrase.map(|p| p.expose_secret()))?; + if entry.has_passphrase { + self.migrate_single_key_to_raw(address, &raw); + } Ok(Plaintext::SingleKey(raw)) } SecretScope::IdentityKey { @@ -662,6 +670,33 @@ impl SecretAccess { SecretSeam::new(&self.inner.secret_store) } + /// LAZY-migrate a just-decrypted protected single key to raw bytes under + /// the same label (the upsert replaces the AES-GCM framing) and flip the + /// in-memory index so the next resolve takes the prompt-free fast-path. + /// Best-effort: a vault-write failure is logged and the key keeps working + /// via the legacy reader. The persistent `ImportedKey.has_passphrase` flip + /// + the user notice are driven by the screen that owns the app k/v. + fn migrate_single_key_to_raw(&self, address: &str, raw: &[u8; SINGLE_KEY_LEN]) { + let label = label_for_address(address); + if let Err(e) = self.seam().put_secret( + &single_key_namespace_id(), + &label, + &platform_wallet_storage::secrets::SecretBytes::from_slice(raw), + ) { + tracing::warn!( + target = "wallet_backend::secret_access", + error = ?e, + "Single-key lazy raw migration deferred (vault write failed)", + ); + return; + } + if let Ok(mut index) = self.inner.single_key_index.write() + && let Some(meta) = index.get_mut(address) + { + meta.has_passphrase = false; + } + } + /// Read the raw 32-byte single-key secret for `address` if the entry has /// already been migrated to its raw label, else `None`. A legacy /// `SingleKeyEntry`-framed value (length != 32) is left for the legacy @@ -1271,6 +1306,57 @@ mod tests { assert_eq!(prompt.ask_count(), 2); } + /// TS-LAZY-03 — a protected single key lazy-migrates through the chokepoint: + /// the first `with_secret` decrypts with the passphrase AND re-stores the + /// raw 32 bytes; a second `with_secret` with a never-prompt host then + /// resolves the SAME bytes prompt-free, and the recovered bytes equal the + /// WIF plaintext. + #[tokio::test] + async fn ts_lazy_03_protected_single_key_migrates_via_chokepoint() { + use dash_sdk::dpp::dashcore::PrivateKey; + + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let address = import_protected_key(&store, SENTINEL_PASSPHRASE); + let expected: [u8; 32] = PrivateKey::from_wif(&known_testnet_wif()) + .unwrap() + .inner[..] + .try_into() + .unwrap(); + + // First resolve: one passphrase, migrates to raw. + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(Arc::clone(&store), prompt.clone()); + let scope = SecretScope::SingleKey { + address: address.clone(), + }; + let first = sa + .with_secret(&scope, |pt| Ok(pt.expose_single_key().copied())) + .await + .unwrap(); + assert_eq!(first, Some(expected)); + assert_eq!(prompt.ask_count(), 1); + + // The vault now holds the raw 32 bytes (migration replaced the framing). + let label = label_for_address(&address); + let stored = store + .get(&single_key_namespace_id(), &label) + .unwrap() + .unwrap(); + assert_eq!(stored.expose_secret().len(), 32, "migrated to raw"); + assert_eq!(stored.expose_secret(), &expected[..]); + + // Second resolve under a fresh never-prompt chokepoint is prompt-free. + let never = Arc::new(TestPrompt::never()); + let sa2 = access(Arc::clone(&store), never.clone()); + let second = sa2 + .with_secret(&scope, |pt| Ok(pt.expose_single_key().copied())) + .await + .expect("prompt-free after migration"); + assert_eq!(second, Some(expected)); + assert_eq!(never.ask_count(), 0, "migrated key resolves prompt-free"); + } + // --- secret confinement (Smythe must-fix #5) -------------------------- #[tokio::test] diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs index cc4345a90..b0d952edf 100644 --- a/src/wallet_backend/single_key.rs +++ b/src/wallet_backend/single_key.rs @@ -308,6 +308,34 @@ impl<'a> SingleKeyView<'a> { Ok(()) } + /// Clear the `has_passphrase` flag on the imported key at `address` in both + /// the in-memory index and the persistent sidecar, after the key's vault + /// secret was lazy-migrated to raw (the passphrase no longer gates it). + /// Idempotent; a no-op success when the address is unknown. + pub fn clear_passphrase_flag(&self, address: &str) -> Result<(), TaskError> { + let updated = { + let mut idx = self + .index + .write() + .map_err(|_| TaskError::ImportedKeyNotFound)?; + let Some(entry) = idx.get_mut(address) else { + return Ok(()); + }; + entry.has_passphrase = false; + entry.passphrase_hint = None; + entry.clone() + }; + if let Some(kv) = self.app_kv { + let key = meta_key_for(self.network, address); + kv.put(DetScope::Global, &key, &updated).map_err(|source| { + TaskError::SingleKeyMetaStorage { + source: Box::new(source), + } + })?; + } + Ok(()) + } + /// Returns `true` when the imported key at `address` was stored /// with a per-key passphrase. The UI uses this to decide whether to /// prompt before signing. @@ -357,8 +385,10 @@ impl<'a> SingleKeyView<'a> { /// Returns [`TaskError::SingleKeyPassphraseIncorrect`] on a wrong /// passphrase (the same generic signal as the restore path — no oracle). /// For an unprotected entry the passphrase is irrelevant and this is an - /// `Ok(())` so callers can treat "ready to use" uniformly. - pub fn verify_passphrase(&self, address: &str, passphrase: &str) -> Result<(), TaskError> { + /// `Ok(false)` so callers can treat "ready to use" uniformly. `Ok(true)` + /// means a protected entry was just lazy-migrated to raw (the caller may + /// surface the one-time disclosure notice). + pub fn verify_passphrase(&self, address: &str, passphrase: &str) -> Result { let label = label_for_address(address); let payload = self .secret_store @@ -370,8 +400,25 @@ impl<'a> SingleKeyView<'a> { let entry = SingleKeyEntry::decode(payload.expose_secret())?; // Decrypt to verify, then drop immediately — the binding is wiped on // drop, so the plaintext never crosses back out of this method. - let _verified: Zeroizing<[u8; 32]> = entry.decrypt(Some(passphrase))?; - Ok(()) + let verified: Zeroizing<[u8; 32]> = entry.decrypt(Some(passphrase))?; + // LAZY migration: a protected entry just unlocked — re-store it raw + // under the same label (the upsert replaces the AES-GCM framing) and + // clear the persistent passphrase flag, so the next use is prompt-free. + // Returns whether a migration ran so the caller can surface the notice. + if entry.has_passphrase { + self.secret_store + .set( + &single_key_namespace_id(), + &label, + &SecretBytes::from_slice(&*verified), + ) + .map_err(|source| TaskError::SecretStore { + source: Box::new(source), + })?; + self.clear_passphrase_flag(address)?; + return Ok(true); + } + Ok(false) } /// List every imported key tracked by this backend, sorted by From dd570b8bf2b8321fae5c87397d6a8f13724ee690 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:56:40 +0200 Subject: [PATCH 14/24] chore: fmt + clippy for the T3-T8 integration batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - secret_access: drop explicit_auto_deref on set_raw(seed_hash, seed) — a &Zeroizing<[u8;64]> auto-derefs to &[u8;64]. - nightly-fmt whitespace across the touched files. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 957 lib + 146 + 10 + 3 + 2 pass, 0 fail, 1 ignored (funded-testnet TS-SIGN-E2E-01); 2 compile_fail doctests pass; det-cli standalone smoke (network-info / core-wallets-list / tools) all green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/backend_task/wallet/mod.rs | 2 +- .../wallet/sign_message_with_identity_key.rs | 3 +- src/context/wallet_lifecycle.rs | 43 +++++----- src/ui/identities/keys/key_info_screen.rs | 8 +- src/wallet_backend/mod.rs | 2 +- src/wallet_backend/secret_access.rs | 60 +++++++------- src/wallet_backend/single_key.rs | 81 ++++++++++--------- 7 files changed, 109 insertions(+), 90 deletions(-) diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index 2d6a7eac7..01759082e 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -16,8 +16,8 @@ use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::Credits; use dash_sdk::dpp::dashcore::OutPoint; -use dash_sdk::dpp::identity::{KeyID, KeyType}; use dash_sdk::dpp::identity::core_script::CoreScript; +use dash_sdk::dpp::identity::{KeyID, KeyType}; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::platform::Identifier; use std::collections::BTreeMap; diff --git a/src/backend_task/wallet/sign_message_with_identity_key.rs b/src/backend_task/wallet/sign_message_with_identity_key.rs index 0a7d402ab..a9491ea9d 100644 --- a/src/backend_task/wallet/sign_message_with_identity_key.rs +++ b/src/backend_task/wallet/sign_message_with_identity_key.rs @@ -50,7 +50,8 @@ impl AppContext { TaskError::WalletMessageSigningFailed })?; let secp = Secp256k1::new(); - let digest = Message::from_digest(*signed_msg_hash(message.as_str()).as_byte_array()); + let digest = + Message::from_digest(*signed_msg_hash(message.as_str()).as_byte_array()); let recoverable = secp.sign_ecdsa_recoverable(&digest, &secret_key); Ok(MessageSignature::new(recoverable, true).to_base64()) }) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 218033702..39e2c7724 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -271,7 +271,9 @@ impl AppContext { .find(|k| k.address == address) .and_then(|k| k.alias) .unwrap_or_else(|| address.to_string()); - let migrated = backend.single_key().verify_passphrase(address, passphrase)?; + let migrated = backend + .single_key() + .verify_passphrase(address, passphrase)?; if migrated { self.show_single_key_migration_notice(&label); } @@ -589,15 +591,13 @@ impl AppContext { // seam: `encrypted_seed_slice()` is the verbatim seed (no DET AES-GCM). // The non-secret metadata rides in `WalletMeta` (write_wallet_meta). if !wallet.uses_password { - let seed: [u8; 64] = - wallet - .encrypted_seed_slice() - .try_into() - .map_err(|_| TaskError::WalletSeedStorage { - source: Box::new( - platform_wallet_storage::secrets::SecretStoreError::MalformedVault, - ), - })?; + let seed: [u8; 64] = wallet.encrypted_seed_slice().try_into().map_err(|_| { + TaskError::WalletSeedStorage { + source: Box::new( + platform_wallet_storage::secrets::SecretStoreError::MalformedVault, + ), + } + })?; return view.set_raw(&seed_hash, &seed); } // Password wallets keep the legacy AES-GCM envelope at creation; they @@ -977,11 +977,7 @@ impl AppContext { passphrase: Option<&str>, ) { let (seed_hash, uses_password, wallet_alias) = match wallet.read() { - Ok(guard) => ( - guard.seed_hash(), - guard.uses_password, - guard.alias.clone(), - ), + Ok(guard) => (guard.seed_hash(), guard.uses_password, guard.alias.clone()), Err(_) => return, }; @@ -2331,7 +2327,10 @@ mod tests { .get_raw(&seed_hash) .expect("vault read must not error") .expect("the raw seed must be persisted at register time, even unwired"); - assert_eq!(&*raw, &seed, "persisted raw seed must equal the wallet seed"); + assert_eq!( + &*raw, &seed, + "persisted raw seed must equal the wallet seed" + ); assert!( WalletSeedView::new(&ctx.secret_store()) .legacy_envelope_get(&seed_hash) @@ -2491,7 +2490,9 @@ mod tests { let store = ctx.secret_store(); let view = WalletSeedView::new(&store); assert!( - view.get_raw(&seed_hash).expect("raw read after removal").is_none(), + view.get_raw(&seed_hash) + .expect("raw read after removal") + .is_none(), "the raw seed must be deleted from the vault on removal" ); assert!( @@ -2609,7 +2610,9 @@ mod tests { let store = ctx.secret_store(); let view = WalletSeedView::new(&store); assert!( - view.get_raw(&seed_hash).expect("raw read after removal").is_none(), + view.get_raw(&seed_hash) + .expect("raw read after removal") + .is_none(), "the raw seed must be deleted from the vault on a fresh install" ); assert!( @@ -2671,7 +2674,9 @@ mod tests { let store = ctx.secret_store(); let view = WalletSeedView::new(&store); assert!( - view.get_raw(&seed_hash).expect("raw read after clear").is_none(), + view.get_raw(&seed_hash) + .expect("raw read after clear") + .is_none(), "raw seed must be deleted from the vault after clear" ); assert!( diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index fde1d1433..64e6907a9 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -2,10 +2,10 @@ use crate::app::AppAction; use crate::backend_task::wallet::WalletTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; +use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; use crate::model::secret::Secret; use crate::model::wallet::Wallet; use crate::ui::components::MessageBanner; @@ -469,8 +469,10 @@ impl ScreenLike for KeyInfoScreen { // fetched just-in-time by a backend task. The UI // only ever sees the derived WIF for display. ui.label( - RichText::new("This signing key is stored securely on this device.") - .color(text_primary), + RichText::new( + "This signing key is stored securely on this device.", + ) + .color(text_primary), ); ui.add_space(10.0); if let Some(private_key) = self.decrypted_private_key { diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 73949f51a..cbd1a8fa2 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -64,12 +64,12 @@ pub(crate) use dashpay::{derive_contact_info_encryption_keys, derive_contact_xpu pub(crate) use det_platform_signer::{DetPlatformSigner, PlatformPathIndex}; pub(crate) use det_signer::DetSigner; +pub use identity_key_store::IdentityKeyView; pub use secret_access::{SecretAccess, SecretPlaintext, SecretSession, WalletPromptMeta}; pub use secret_prompt::{ NullSecretPrompt, RememberPolicy, SecretPrompt, SecretPromptCancelled, SecretPromptReply, SecretPromptRequest, SecretPromptRetry, SecretScope, }; -pub use identity_key_store::IdentityKeyView; pub use secret_seam::SecretSeam; use coordinator_gate::CoordinatorGate; diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index a0879ede3..0453dfc88 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -445,13 +445,11 @@ impl SecretAccess { let plaintext = self.decrypt_jit(&scope, passphrase)?; let mut migrated = false; - if !already_raw - && let Plaintext::HdSeed(seed) = &plaintext - { + if !already_raw && let Plaintext::HdSeed(seed) = &plaintext { // The seed came from the legacy envelope. Re-store it raw // (vault-first), then drop the legacy envelope. let view = WalletSeedView::new(&self.inner.secret_store); - view.set_raw(seed_hash, &**seed)?; + view.set_raw(seed_hash, seed)?; view.delete(seed_hash)?; migrated = true; } @@ -565,7 +563,11 @@ impl SecretAccess { match scope { SecretScope::HdSeed { seed_hash } => { // Raw seed present ⇒ migrated ⇒ no passphrase. - if self.seam().get_secret(&seed_scope(seed_hash), SEED_RAW_LABEL)?.is_some() { + if self + .seam() + .get_secret(&seed_scope(seed_hash), SEED_RAW_LABEL)? + .is_some() + { return Ok(false); } let view = WalletSeedView::new(&self.inner.secret_store); @@ -603,18 +605,18 @@ impl SecretAccess { ) -> Result { match scope { SecretScope::HdSeed { seed_hash } => { - if let Some(raw) = - self.seam().get_secret(&seed_scope(seed_hash), SEED_RAW_LABEL)? + if let Some(raw) = self + .seam() + .get_secret(&seed_scope(seed_hash), SEED_RAW_LABEL)? { - let seed: [u8; HD_SEED_LEN] = - raw.expose_secret().try_into().map_err(|_| { - tracing::warn!( - target = "wallet_backend::secret_access", - blob_len = raw.expose_secret().len(), - "Raw seam seed has wrong length", - ); - TaskError::SecretDecryptFailed - })?; + let seed: [u8; HD_SEED_LEN] = raw.expose_secret().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::secret_access", + blob_len = raw.expose_secret().len(), + "Raw seam seed has wrong length", + ); + TaskError::SecretDecryptFailed + })?; return Ok(Plaintext::HdSeed(Zeroizing::new(seed))); } // Legacy fallback (migration reader). Neither raw nor legacy @@ -651,15 +653,14 @@ impl SecretAccess { .seam() .get_secret(&SecretWalletId::from(*identity_id), &label)? .ok_or(TaskError::IdentityKeyMissing)?; - let key: [u8; SINGLE_KEY_LEN] = - raw.expose_secret().try_into().map_err(|_| { - tracing::warn!( - target = "wallet_backend::secret_access", - blob_len = raw.expose_secret().len(), - "Raw identity key has wrong length", - ); - TaskError::SecretDecryptFailed - })?; + let key: [u8; SINGLE_KEY_LEN] = raw.expose_secret().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::secret_access", + blob_len = raw.expose_secret().len(), + "Raw identity key has wrong length", + ); + TaskError::SecretDecryptFailed + })?; Ok(Plaintext::IdentityKey(Zeroizing::new(key))) } } @@ -701,7 +702,10 @@ impl SecretAccess { /// already been migrated to its raw label, else `None`. A legacy /// `SingleKeyEntry`-framed value (length != 32) is left for the legacy /// reader and reported as `None` here. - fn single_key_raw(&self, address: &str) -> Result>, TaskError> { + fn single_key_raw( + &self, + address: &str, + ) -> Result>, TaskError> { let label = label_for_address(address); let Some(payload) = self.seam().get_secret(&single_key_namespace_id(), &label)? else { return Ok(None); @@ -1318,9 +1322,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let store = fresh_store(dir.path()); let address = import_protected_key(&store, SENTINEL_PASSPHRASE); - let expected: [u8; 32] = PrivateKey::from_wif(&known_testnet_wif()) - .unwrap() - .inner[..] + let expected: [u8; 32] = PrivateKey::from_wif(&known_testnet_wif()).unwrap().inner[..] .try_into() .unwrap(); diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs index b0d952edf..722501d48 100644 --- a/src/wallet_backend/single_key.rs +++ b/src/wallet_backend/single_key.rs @@ -217,41 +217,44 @@ impl<'a> SingleKeyView<'a> { // legacy AES-GCM `SingleKeyEntry` at import and migrate to raw lazily on // the next unlock through the chokepoint. The locked-render pubkey lives // in the `ImportedKey` sidecar either way. - let (has_passphrase, passphrase_hint) = - match passphrase.passphrase.as_ref().map(|p| p.as_str()) { - Some(p) if !p.is_empty() => { - if p.chars().count() < MIN_SINGLE_KEY_PASSPHRASE_LEN { - return Err(TaskError::SingleKeyPassphraseTooShort { - min: MIN_SINGLE_KEY_PASSPHRASE_LEN as u32, - }); - } - let entry = - SingleKeyEntry::protected(&raw, p, passphrase.hint.clone(), pub_bytes.clone())?; - let payload = entry.encode()?; - self.secret_store - .set( - &single_key_namespace_id(), - &label, - &SecretBytes::from_slice(&payload), - ) - .map_err(|source| TaskError::SecretStore { - source: Box::new(source), - })?; - (true, passphrase.hint.clone()) - } - _ => { - self.secret_store - .set( - &single_key_namespace_id(), - &label, - &SecretBytes::from_slice(&*raw), - ) - .map_err(|source| TaskError::SecretStore { - source: Box::new(source), - })?; - (false, None) + let (has_passphrase, passphrase_hint) = match passphrase + .passphrase + .as_ref() + .map(|p| p.as_str()) + { + Some(p) if !p.is_empty() => { + if p.chars().count() < MIN_SINGLE_KEY_PASSPHRASE_LEN { + return Err(TaskError::SingleKeyPassphraseTooShort { + min: MIN_SINGLE_KEY_PASSPHRASE_LEN as u32, + }); } - }; + let entry = + SingleKeyEntry::protected(&raw, p, passphrase.hint.clone(), pub_bytes.clone())?; + let payload = entry.encode()?; + self.secret_store + .set( + &single_key_namespace_id(), + &label, + &SecretBytes::from_slice(&payload), + ) + .map_err(|source| TaskError::SecretStore { + source: Box::new(source), + })?; + (true, passphrase.hint.clone()) + } + _ => { + self.secret_store + .set( + &single_key_namespace_id(), + &label, + &SecretBytes::from_slice(&*raw), + ) + .map_err(|source| TaskError::SecretStore { + source: Box::new(source), + })?; + (false, None) + } + }; let imported = ImportedKey { address: address_str.clone(), @@ -1615,7 +1618,9 @@ mod tests { network, app_kv: Some(&kv), }; - let imported = view.import_wif(known_wif(), Some("raw".into())).expect("import"); + let imported = view + .import_wif(known_wif(), Some("raw".into())) + .expect("import"); assert!(!imported.has_passphrase); assert!( !imported.public_key_bytes.is_empty(), @@ -1628,7 +1633,11 @@ mod tests { .get(&single_key_namespace_id(), &label) .expect("get") .expect("present"); - assert_eq!(raw.expose_secret().len(), 32, "raw, not a versioned envelope"); + assert_eq!( + raw.expose_secret().len(), + 32, + "raw, not a versioned envelope" + ); let priv_key = PrivateKey::from_wif(known_wif()).unwrap(); assert_eq!(raw.expose_secret(), &priv_key.inner[..]); From c1550202be03521b727b6dbcac461ca000301839 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:18:28 +0200 Subject: [PATCH 15/24] fix(wallet-backend): dual-format read for WalletMeta + ImportedKey sidecars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real defect QA caught (PROJ-001/002/003 + SEC-003): appending fields to a positional-bincode DetKv value is format-breaking, and my T5 framing made it WORSE — WalletMeta writes went through kv.put::>(versioned-frame) and reads through kv.get::>, which type-confuses an OLD kv.put:: blob (decodes the alias's UTF-8 bytes AS the Vec) → alias/is_main silently lost. ImportedKey appended public_key_bytes with no legacy reader → old keys vanish from the picker. Fix (one policy for both sibling sidecars): drop the hand-rolled version byte (SEC-003: it could collide with a bincode length varint — a 1/2-char alias). Instead lean on the DetKv schema envelope + try-decode-both: - write the current shape directly (kv.put:: / ::); - on read, try the current shape; on a bincode Decode error (an old blob runs out of bytes for the appended fields) fall back to the legacy shape (WalletMetaV1 / ImportedKeyV1, decode-only) and RE-STORE in the new shape. Order is load-bearing and tested: the 6-field struct CANNOT decode a 4-field blob (runs past end), so "new first, then V1" never mis-promotes. A DetKv schema-version mismatch stays a hard error; only Decode triggers the fallback. Removes the now-dead encode_versioned/decode_versioned/WALLET_META_VERSION (PROJ-002 — the unreachable legacy branch + its overclaiming test are gone; the legacy path is now live via the view and tested end-to-end). Tests: model leg (ts_meta_01) asserts the order-sensitivity + the SEC-003 1/2-char-alias collision case; view legs (old_wallet_meta_blob_*, old_imported_key_blob_*) write an OLD blob exactly as the base branch did, read it back through the view preserving every field, and confirm re-store in the new shape. wallet::meta 3, wallet_meta 13, single_key all green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/model/single_key.rs | 39 ++++++++ src/model/wallet/meta.rs | 149 +++++++++++------------------- src/wallet_backend/single_key.rs | 85 ++++++++++++++++- src/wallet_backend/wallet_meta.rs | 84 ++++++++++++++--- 4 files changed, 251 insertions(+), 106 deletions(-) diff --git a/src/model/single_key.rs b/src/model/single_key.rs index 9e91df15d..db6007611 100644 --- a/src/model/single_key.rs +++ b/src/model/single_key.rs @@ -58,3 +58,42 @@ pub struct ImportedKey { #[serde(default)] pub public_key_bytes: Vec, } + +/// The pre-`public_key_bytes` [`ImportedKey`] on-disk shape, decode-only. +/// +/// `ImportedKey` is a positional-bincode `DetKv` value; appending +/// `public_key_bytes` is format-breaking for blobs already written without it +/// (`#[serde(default)]` does not rescue a trailing positional field). The +/// single-key read path tries the current shape first, then falls back to this +/// legacy shape and re-stores in the new shape — the same dual-format treatment +/// `WalletMeta` gets, so the two sibling sidecars share one policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportedKeyV1 { + /// See [`ImportedKey::address`]. + pub address: String, + /// See [`ImportedKey::alias`]. + pub alias: Option, + /// See [`ImportedKey::network`]. + pub network: Network, + /// See [`ImportedKey::has_passphrase`]. + #[serde(default)] + pub has_passphrase: bool, + /// See [`ImportedKey::passphrase_hint`]. + #[serde(default)] + pub passphrase_hint: Option, +} + +impl From for ImportedKey { + fn from(v1: ImportedKeyV1) -> Self { + ImportedKey { + address: v1.address, + alias: v1.alias, + network: v1.network, + has_passphrase: v1.has_passphrase, + passphrase_hint: v1.passphrase_hint, + // Pre-this-field blobs have no stored pubkey; locked render falls + // back to deriving from plaintext when the key is unlocked. + public_key_bytes: Vec::new(), + } + } +} diff --git a/src/model/wallet/meta.rs b/src/model/wallet/meta.rs index 375ad084d..db122a75c 100644 --- a/src/model/wallet/meta.rs +++ b/src/model/wallet/meta.rs @@ -19,23 +19,18 @@ use serde::{Deserialize, Serialize}; -/// On-disk version tag for the bincode-encoded [`WalletMeta`] payload, framed -/// by [`WalletMetaView`](crate::wallet_backend::WalletMetaView) as -/// `[ WALLET_META_VERSION (1B) | bincode(WalletMeta) ]`. +/// The original (pre-`uses_password`) [`WalletMeta`] on-disk shape, decode-only. /// -/// `WalletMeta` is positional bincode, so adding a field is format-breaking for -/// already-stored blobs — `#[serde(default)]` alone does NOT make a stored blob -/// forward-compatible (it only supplies a value at the Rust layer when a field -/// is genuinely absent from the encoded stream, which positional bincode never -/// reports). This explicit version byte is the gate: v1 is the original shape -/// (no `uses_password` / `password_hint`); v2 adds them. The reader detects the -/// version and migrates a v1 blob to v2 with the new fields defaulted, rather -/// than positionally misparsing it. -pub const WALLET_META_VERSION: u8 = 2; - -/// The original (pre-`uses_password`) [`WalletMeta`] on-disk shape. Retained -/// decode-only so a v1 blob (or a pre-version-byte legacy blob) migrates into -/// the current shape instead of being misread. +/// `WalletMeta` is a positional-bincode `DetKv` value, so appending the +/// `uses_password` / `password_hint` fields is format-breaking for blobs +/// already written in the 4-field shape — `#[serde(default)]` does NOT rescue +/// them (positional bincode never reports an absent trailing field; it reads +/// past the end and errors). The dual-format reader +/// ([`WalletMetaView::get`](crate::wallet_backend::WalletMetaView)) tries the +/// current shape first, then falls back to decoding this legacy shape, and +/// re-stores in the new shape. No version byte: the two shapes are told apart +/// by which one decodes, leaning on the `DetKv` schema envelope rather than a +/// hand-rolled tag that could collide with a bincode length varint. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct WalletMetaV1 { /// See [`WalletMeta::alias`]. @@ -97,9 +92,9 @@ pub struct WalletMeta { /// NOT make this blob forward-compatible. `WalletMeta` is stored as a /// positional `bincode::config::standard()` blob behind the `DetKv` /// schema envelope, so adding, removing, or reordering any field here is - /// a format-breaking change for already-stored blobs. Evolve the shape - /// only by bumping [`WALLET_META_VERSION`] and migrating old blobs (see - /// [`WalletMetaV1`]). + /// a format-breaking change for already-stored blobs. Evolve the shape by + /// adding a decode-only legacy shape (see [`WalletMetaV1`]) and a + /// dual-format reader, never by relying on `#[serde(default)]` alone. #[serde(default)] pub xpub_encoded: Vec, /// `true` when the wallet's seed was stored under a user password. Moved @@ -114,44 +109,6 @@ pub struct WalletMeta { pub password_hint: Option, } -/// Encode a [`WalletMeta`] for storage as `[ WALLET_META_VERSION | bincode ]`. -/// The leading version byte lets the reader migrate older shapes instead of -/// positionally misparsing them. -pub fn encode_versioned(meta: &WalletMeta) -> Result, bincode::error::EncodeError> { - let body = bincode::serde::encode_to_vec(meta, bincode::config::standard())?; - let mut out = Vec::with_capacity(body.len() + 1); - out.push(WALLET_META_VERSION); - out.extend_from_slice(&body); - Ok(out) -} - -/// Decode a stored [`WalletMeta`] payload, handling every on-disk shape: -/// -/// * leading [`WALLET_META_VERSION`] (current v2) → decode directly; -/// * leading version byte `1` → decode as [`WalletMetaV1`] and migrate; -/// * no recognised version byte (pre-version-byte legacy blob) → try v1 bare -/// bincode and migrate. -/// -/// A blob that matches none of these is a decode error — never a positional -/// misparse. -pub fn decode_versioned(bytes: &[u8]) -> Result { - let cfg = bincode::config::standard(); - if let Some((&tag, rest)) = bytes.split_first() { - if tag == WALLET_META_VERSION { - let (meta, _) = bincode::serde::decode_from_slice::(rest, cfg)?; - return Ok(meta); - } - if tag == 1 - && let Ok((v1, _)) = bincode::serde::decode_from_slice::(rest, cfg) - { - return Ok(v1.into()); - } - } - // Pre-version-byte legacy blob: bare v1 bincode. - let (v1, _) = bincode::serde::decode_from_slice::(bytes, cfg)?; - Ok(v1.into()) -} - #[cfg(test)] mod tests { use super::*; @@ -190,12 +147,45 @@ mod tests { assert!(m.password_hint.is_none()); } - /// TS-META-01 — the new v2 shape round-trips through the versioned framing - /// field-for-field, and an OLD v1 blob is detected by its version byte and - /// migrated (NOT positionally misparsed). The migrated meta defaults - /// `uses_password=false` / `password_hint=None` and carries every v1 field. + /// TS-META-01 (model leg) — the dual-format decode contract the view's + /// `read_meta` relies on: a legacy 4-field blob FAILS to decode as the new + /// 6-field `WalletMeta` (runs out of bytes) but decodes as `WalletMetaV1`; + /// a 6-field blob decodes as `WalletMeta`. This is why "try new, then V1" + /// is correct and order-sensitive. Includes the SEC-003 collision case (a + /// 1-char alias, whose bincode length varint is `1`) — the old leading-byte + /// dispatch would have mis-routed it; the try-both reader does not. #[test] - fn ts_meta_01_versioned_frame_round_trip_and_v1_migration() { + fn ts_meta_01_dual_format_decode_is_order_sensitive() { + let cfg = bincode::config::standard(); + + for alias in ["paycheque", "a", "ab"] { + let v1 = WalletMetaV1 { + alias: alias.into(), + is_main: true, + core_wallet_name: Some("dev".into()), + xpub_encoded: vec![0x22; 78], + }; + let old_blob = bincode::serde::encode_to_vec(&v1, cfg).expect("encode v1"); + + // The new 6-field struct cannot decode the 4-field blob. + assert!( + bincode::serde::decode_from_slice::(&old_blob, cfg).is_err(), + "legacy blob (alias {alias:?}) must NOT decode as the new shape", + ); + // The legacy struct does. + let (decoded, _): (WalletMetaV1, _) = + bincode::serde::decode_from_slice(&old_blob, cfg).expect("decode v1"); + assert_eq!(decoded, v1); + + // Migration preserves the v1 fields, defaults the new ones. + let migrated: WalletMeta = decoded.into(); + assert_eq!(migrated.alias, alias); + assert_eq!(migrated.xpub_encoded, vec![0x22; 78]); + assert!(!migrated.uses_password); + assert!(migrated.password_hint.is_none()); + } + + // A new 6-field blob decodes as WalletMeta (and re-stores identically). let v2 = WalletMeta { alias: "paycheque".into(), is_main: true, @@ -204,36 +194,9 @@ mod tests { uses_password: true, password_hint: Some("hint".into()), }; - let framed = encode_versioned(&v2).expect("encode v2"); - assert_eq!( - framed[0], WALLET_META_VERSION, - "frame starts with the version tag" - ); - assert_eq!(decode_versioned(&framed).expect("decode v2"), v2); - - // A v1 blob: framed with version byte 1 over the old shape. - let v1 = WalletMetaV1 { - alias: "legacy".into(), - is_main: false, - core_wallet_name: None, - xpub_encoded: vec![0x22; 78], - }; - let v1_body = - bincode::serde::encode_to_vec(&v1, bincode::config::standard()).expect("encode v1"); - let mut v1_framed = vec![1u8]; - v1_framed.extend_from_slice(&v1_body); - let migrated = decode_versioned(&v1_framed).expect("decode + migrate v1"); - assert_eq!(migrated.alias, "legacy"); - assert_eq!(migrated.xpub_encoded, vec![0x22; 78]); - assert!( - !migrated.uses_password, - "v1 migrates with uses_password defaulted false" - ); - assert!(migrated.password_hint.is_none()); - - // A pre-version-byte legacy blob (bare v1 bincode) also migrates. - let bare = decode_versioned(&v1_body).expect("decode + migrate bare v1"); - assert_eq!(bare.alias, "legacy"); - assert_eq!(WalletMeta::from(v1), bare); + let new_blob = bincode::serde::encode_to_vec(&v2, cfg).expect("encode v2"); + let (decoded, _): (WalletMeta, _) = + bincode::serde::decode_from_slice(&new_blob, cfg).expect("decode v2"); + assert_eq!(decoded, v2); } } diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs index 722501d48..0497a744e 100644 --- a/src/wallet_backend/single_key.rs +++ b/src/wallet_backend/single_key.rs @@ -482,7 +482,7 @@ impl<'a> SingleKeyView<'a> { }; let mut out = Vec::with_capacity(keys.len()); for key in keys { - match kv.get::(DetScope::Global, &key) { + match self.read_imported_key(kv, &key) { Ok(Some(meta)) => out.push(meta), Ok(None) => {} Err(e) => { @@ -498,6 +498,39 @@ impl<'a> SingleKeyView<'a> { out } + /// Read one `ImportedKey` sidecar blob with a dual-format fallback. Tries + /// the current shape first; on a decode failure (an old blob lacks the + /// appended `public_key_bytes`) falls back to the legacy [`ImportedKeyV1`] + /// shape and RE-STORES it in the current shape — so an imported key created + /// before that field still appears in the picker instead of vanishing. + /// Mirrors the `WalletMeta` dual-format reader. + fn read_imported_key( + &self, + kv: &Arc, + key: &str, + ) -> Result, crate::wallet_backend::KvAdapterError> { + use crate::wallet_backend::KvAdapterError; + match kv.get::(DetScope::Global, key) { + Ok(opt) => return Ok(opt), + Err(KvAdapterError::Decode(_)) => {} + Err(e) => return Err(e), + } + let Some(v1) = kv.get::(DetScope::Global, key)? + else { + return Ok(None); + }; + let migrated: ImportedKey = v1.into(); + if let Err(e) = kv.put(DetScope::Global, key, &migrated) { + tracing::warn!( + target = "wallet_backend::single_key", + key = %key, + error = ?e, + "Could not re-store migrated single-key sidecar; will retry next read", + ); + } + Ok(Some(migrated)) + } + /// Reconstruct DET-side [`SingleKeyWallet`] rows from the k/v sidecar /// plus the encrypted secret vault. Used by the cold-boot hydration /// path that replaces the legacy `db.get_single_key_wallets` read. @@ -1645,4 +1678,54 @@ mod tests { view.sign_with(&imported.address, &[0x42u8; 32]) .expect("raw key signs"); } + + /// PROJ-003 — an OLD `ImportedKey` sidecar blob written WITHOUT the + /// appended `public_key_bytes` (the pre-this-PR 5-field shape) is read back + /// through the view's dual-format fallback: it does NOT vanish from the + /// picker, its fields are preserved, and it is re-stored in the new shape. + #[test] + fn old_imported_key_blob_decodes_and_restores() { + use crate::model::single_key::ImportedKeyV1; + + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + + // Write the OLD 5-field shape directly, the way the base branch did. + let address = "yTestImportedAddr".to_string(); + let key = meta_key_for(network, &address); + let v1 = ImportedKeyV1 { + address: address.clone(), + alias: Some("legacy key".into()), + network, + has_passphrase: true, + passphrase_hint: Some("the usual".into()), + }; + kv.put(DetScope::Global, &key, &v1).expect("write old blob"); + + // The view lists it (dual-format fallback) — not skipped. + let listed = view.list_persisted(); + assert_eq!(listed.len(), 1, "old key must not vanish from the picker"); + let got = &listed[0]; + assert_eq!(got.address, address); + assert_eq!(got.alias.as_deref(), Some("legacy key")); + assert!(got.has_passphrase); + assert_eq!(got.passphrase_hint.as_deref(), Some("the usual")); + assert!(got.public_key_bytes.is_empty(), "no stored pubkey pre-migration"); + + // It was re-stored in the new shape: a direct new-shape decode succeeds. + let direct: Option = + kv.get(DetScope::Global, &key).expect("direct new-shape read"); + assert_eq!(direct.expect("present").address, address); + } } diff --git a/src/wallet_backend/wallet_meta.rs b/src/wallet_backend/wallet_meta.rs index e719448de..6afdf9d11 100644 --- a/src/wallet_backend/wallet_meta.rs +++ b/src/wallet_backend/wallet_meta.rs @@ -30,7 +30,7 @@ use dash_sdk::dpp::dashcore::base58; use crate::backend_task::error::TaskError; use crate::model::wallet::WalletSeedHash; -use crate::model::wallet::meta::{WalletMeta, decode_versioned, encode_versioned}; +use crate::model::wallet::meta::{WalletMeta, WalletMetaV1}; use crate::wallet_backend::kv::KvAdapterError; use crate::wallet_backend::{DetKv, DetScope}; @@ -146,7 +146,8 @@ impl<'a> WalletMetaView<'a> { } /// Upsert the metadata for a single wallet. Re-writing the same - /// value is a no-op-effective write (DetKv upserts by key). + /// value is a no-op-effective write (DetKv upserts by key). Written in the + /// current `WalletMeta` shape directly through the `DetKv` schema envelope. pub fn set( &self, network: Network, @@ -154,23 +155,40 @@ impl<'a> WalletMetaView<'a> { meta: &WalletMeta, ) -> Result<(), TaskError> { let key = key_for(network, seed_hash); - let framed = encode_versioned(meta) - .map_err(|e| map_kv_error_to_task_error(KvAdapterError::Encode(e)))?; self.kv - .put(DetScope::Global, &key, &framed) + .put(DetScope::Global, &key, meta) .map_err(map_kv_error_to_task_error) } - /// Read and version-decode a single wallet-meta blob, migrating a v1 (or - /// pre-version-byte legacy) shape into the current [`WalletMeta`]. - /// `Ok(None)` when the key is absent. + /// Read a single wallet-meta blob with a dual-format fallback. Tries the + /// current 6-field [`WalletMeta`] shape first; on a decode failure (an old + /// 4-field blob runs out of bytes for the appended fields) falls back to the + /// legacy [`WalletMetaV1`] shape and RE-STORES it in the current shape + /// (one-shot migration). `Ok(None)` when the key is absent. fn read_meta(&self, key: &str) -> Result, KvAdapterError> { - let Some(framed) = self.kv.get::>(DetScope::Global, key)? else { + // New shape first. The DetKv schema-version mismatch is a hard error + // (propagate); only a bincode *decode* failure means "try legacy". + match self.kv.get::(DetScope::Global, key) { + Ok(opt) => return Ok(opt), + Err(KvAdapterError::Decode(_)) => {} + Err(e) => return Err(e), + } + // Legacy 4-field shape. A success here is an old blob: migrate it. + let Some(v1) = self.kv.get::(DetScope::Global, key)? else { return Ok(None); }; - decode_versioned(&framed) - .map(Some) - .map_err(KvAdapterError::Decode) + let migrated: WalletMeta = v1.into(); + if let Err(e) = self.kv.put(DetScope::Global, key, &migrated) { + // Re-store is best-effort: the in-memory value is correct this + // session; the next read retries the migration. + tracing::warn!( + target = "wallet_backend::wallet_meta", + key = %key, + error = ?e, + "Could not re-store migrated wallet meta; will retry next read", + ); + } + Ok(Some(migrated)) } /// Delete the metadata for a single wallet. Idempotent — a @@ -390,4 +408,46 @@ mod tests { let decoded = base58::decode(suffix).expect("base58 decodes"); assert_eq!(decoded.as_slice(), seed.as_slice()); } + + /// PROJ-001/002/003 (WalletMeta leg) — an OLD 4-field blob, written exactly + /// as the base branch did (`kv.put::`), is read back through + /// the view: its `alias`/`is_main`/`core_wallet_name`/`xpub` are preserved + /// (NOT silently lost to a `Vec` type-confusion), the new fields default, + /// and the entry is RE-STORED in the new 6-field shape (a subsequent + /// `get::` succeeds directly). Covers a 1-char alias (the + /// SEC-003 leading-byte-collision case). Makes the `WalletMetaV1` legacy + /// path live + tested end-to-end. + #[test] + fn old_wallet_meta_blob_decodes_preserves_fields_and_restores() { + for alias in ["paycheque", "a", "ab"] { + let kv = kv(); + let view = WalletMetaView::new(&kv); + let seed: WalletSeedHash = [0x5A; 32]; + let key = key_for(Network::Testnet, &seed); + + // Write the OLD shape directly, the way the base branch did. + let v1 = WalletMetaV1 { + alias: alias.into(), + is_main: true, + core_wallet_name: Some("local-dashd".into()), + xpub_encoded: vec![0x22; 78], + }; + kv.put(DetScope::Global, &key, &v1).expect("write old blob"); + + // The view reads it (dual-format fallback), preserving every field. + let got = view.get(Network::Testnet, &seed).expect("old blob decodes"); + assert_eq!(got.alias, alias, "alias preserved"); + assert!(got.is_main, "is_main preserved"); + assert_eq!(got.core_wallet_name.as_deref(), Some("local-dashd")); + assert_eq!(got.xpub_encoded, vec![0x22; 78]); + assert!(!got.uses_password, "new field defaults false"); + assert!(got.password_hint.is_none()); + + // It was re-stored in the new shape: a direct new-shape decode now + // succeeds (no more legacy fallback needed). + let direct: Option = + kv.get(DetScope::Global, &key).expect("direct new-shape read"); + assert_eq!(direct.expect("present").alias, alias); + } + } } From 12b34f379d93b37ba862139f9f9543e035963164 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:22:54 +0200 Subject: [PATCH 16/24] test(identity-db): identity-key migration, deletion, write-fault no-loss (QA-002/003/005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the eager identity-key migration core out of AppContext into a free fn migrate_keystore_to_vault(secret_store, id, qi, persist) returning a KeystoreMigration outcome, so the funds-safety logic is unit-testable with a bare SecretStore + a controllable persist closure (no full AppContext). QA-002 — migration is vault-FIRST: the persist closure asserts the raw keys are already in the vault and the blob being persisted is InVault-only; the AtWalletDerivationPath key is untouched; zero plaintext remains; idempotent (second run = Nothing). QA-005 — write-fault no-loss (the write half CRASH-01's read half misses): with the vault parent dir chmod'd read-only so store_all fails, the migration restores the resident plaintext keystore byte-for-byte, does NOT call persist, and reports VaultWriteFailed — keys never lost on a mid-write fault. (#[cfg(unix)].) QA-003 — identity-key deletion is scoped + isolated: delete_all over the victim's (target,key_id) set removes its vault keys while a second identity's key under the same (target,key_id) is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/context/identity_db.rs | 359 +++++++++++++++++++++++++++++++++---- 1 file changed, 322 insertions(+), 37 deletions(-) diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 0392e38d9..d5ea2620d 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -226,6 +226,74 @@ fn index_remove_identity( /// scheduled votes) and prune the scheduled-vote voter index. Does not /// touch the Global identity index — callers decide whether to drop the /// index entry (single delete) or rewrite it wholesale (devnet sweep). +/// Outcome of [`migrate_keystore_to_vault`], so callers/tests can assert what +/// happened without re-inspecting the blob. +#[derive(Debug, PartialEq, Eq)] +enum KeystoreMigration { + /// No plaintext keys to migrate — `qi` was untouched. + Nothing, + /// The vault write failed; `qi` was restored to its resident plaintext and + /// the blob was NOT persisted (next load retries — no key loss). + VaultWriteFailed, + /// `n` keys moved to the vault and `qi` rewritten to `InVault` placeholders. + Migrated(usize), +} + +/// EAGER identity-key migration core (vault-first, crash-safe). Moves any +/// plaintext `Clear`/`AlwaysClear` keys in `qi` into the vault as raw bytes, +/// then asks `persist` to rewrite the blob with `InVault` placeholders. +/// +/// Ordering is the funds-safety contract: vault `store_all` happens FIRST. On a +/// vault-write failure `qi` is restored to its pre-migration resident plaintext +/// (so this session can still sign) and `persist` is NOT called — the legacy +/// blob stays for the next retry, and no key is lost on a mid-write fault. A +/// `persist` failure after a successful vault write is recoverable: the legacy +/// blob plus the now-redundant raw vault entries are re-detected next load and +/// the migration re-runs idempotently. +/// +/// Factored out of [`AppContext`] so it is unit-testable with a bare +/// `SecretStore` and a controllable `persist` closure. +fn migrate_keystore_to_vault( + secret_store: &Arc, + id: &[u8; 32], + qi: &mut QualifiedIdentity, + persist: impl FnOnce(&QualifiedIdentity) -> std::result::Result<(), TaskError>, +) -> KeystoreMigration { + let before = qi.private_keys.clone(); + let taken = qi.private_keys.take_plaintext_for_vault(); + if taken.is_empty() { + return KeystoreMigration::Nothing; + } + let view = crate::wallet_backend::IdentityKeyView::new(secret_store, *id); + if let Err(e) = view.store_all(&taken) { + qi.private_keys = before; + tracing::warn!( + target = "context::identity_db", + identity = %hex::encode(id), + error = ?e, + "Identity-key vault migration deferred (vault write failed)", + ); + return KeystoreMigration::VaultWriteFailed; + } + let migrated = taken.len(); + if let Err(e) = persist(qi) { + tracing::warn!( + target = "context::identity_db", + identity = %hex::encode(id), + error = ?e, + "Identity-key blob rewrite deferred after vault migration", + ); + } else { + tracing::info!( + target = "context::identity_db", + identity = %hex::encode(id), + migrated, + "Migrated identity keys to the secret vault", + ); + } + KeystoreMigration::Migrated(migrated) +} + fn purge_identity_scope( kv: &crate::wallet_backend::DetKv, id: &[u8; 32], @@ -641,43 +709,9 @@ impl AppContext { id: &[u8; 32], qi: &mut QualifiedIdentity, ) { - let before = qi.private_keys.clone(); - let taken = qi.private_keys.take_plaintext_for_vault(); - if taken.is_empty() { - return; - } - let view = crate::wallet_backend::IdentityKeyView::new(&self.secret_store, *id); - if let Err(e) = view.store_all(&taken) { - // Vault-first failed: restore the resident plaintext so this - // session can still sign, and leave the blob for the next retry. - qi.private_keys = before; - tracing::warn!( - target = "context::identity_db", - identity = %hex::encode(id), - error = ?e, - "Identity-key vault migration deferred (vault write failed)", - ); - return; - } - // Vault holds the raw bytes; rewrite the blob with the InVault - // placeholders. A failure here is recoverable — the legacy plaintext - // blob plus the (now redundant) raw vault entries are re-detected next - // load and the migration re-runs idempotently. - if let Err(e) = self.persist_identity_blob(kv, id, qi) { - tracing::warn!( - target = "context::identity_db", - identity = %hex::encode(id), - error = ?e, - "Identity-key blob rewrite deferred after vault migration", - ); - } else { - tracing::info!( - target = "context::identity_db", - identity = %hex::encode(id), - migrated = taken.len(), - "Migrated identity keys to the secret vault", - ); - } + let _ = migrate_keystore_to_vault(&self.secret_store, id, qi, |migrated| { + self.persist_identity_blob(kv, id, migrated) + }); } /// Re-persist `qi`'s blob in place, preserving the stored wallet @@ -1417,4 +1451,255 @@ mod tests { "a dangling index entry must not resolve to a blob" ); } + + // --------------------------------------------------------------- + // Identity-key vault migration + deletion (funds-safety). + // --------------------------------------------------------------- + + use crate::model::qualified_identity::encrypted_key_storage::{ + KeyStorage, PrivateKeyData, WalletDerivationPath, + }; + use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget}; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::wallet_backend::IdentityKeyView; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::key_wallet::bip32::DerivationPath; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + + fn fresh_vault(dir: &std::path::Path) -> Arc { + let path = dir.join("secrets.pwsvault"); + Arc::new( + crate::wallet_backend::single_key::open_secret_store(&path).expect("open vault"), + ) + } + + /// A `QualifiedIdentity` carrying one `Clear` (HIGH), one `AlwaysClear` + /// (MEDIUM), and one `AtWalletDerivationPath` key. Returns the QI plus the + /// `(target, key_id)` of each plaintext key for assertions. + fn qi_with_plaintext_and_derived( + secret_high: [u8; 32], + secret_medium: [u8; 32], + ) -> QualifiedIdentity { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + let high = IdentityPublicKey::random_key(1, Some(1), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, high.id()), + ( + QualifiedIdentityPublicKey::from(high), + PrivateKeyData::Clear(secret_high), + ), + ); + let medium = IdentityPublicKey::random_key(2, Some(2), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, medium.id()), + ( + QualifiedIdentityPublicKey::from(medium), + PrivateKeyData::AlwaysClear(secret_medium), + ), + ); + let derived = IdentityPublicKey::random_key(3, Some(3), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, derived.id()), + ( + QualifiedIdentityPublicKey::from(derived), + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x07; 32], + derivation_path: DerivationPath::from(vec![]), + }), + ), + ); + let identity = + Identity::create_basic_identity(Identifier::default(), pv).expect("basic identity"); + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + /// QA-002 — `migrate_keystore_to_vault` content-detects Clear/AlwaysClear, + /// stores them in the vault FIRST, then rewrites the blob to InVault. + /// Asserts: vault-first (the raw bytes are present), the wallet-derived key + /// is untouched, zero plaintext remains, and the persist closure ran AFTER + /// the vault holds the keys. + #[test] + fn qa_002_migrate_keystore_to_vault_vault_first_then_blob() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_vault(dir.path()); + let id = id(0x11); + let high = [0xAA; 32]; + let medium = [0xBB; 32]; + let mut qi = qi_with_plaintext_and_derived(high, medium); + + let view = IdentityKeyView::new(&store, id); + let mut persisted = false; + let outcome = migrate_keystore_to_vault(&store, &id, &mut qi, |migrated| { + // Vault-FIRST: by the time persist runs, the raw keys are stored. + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .unwrap() + .is_some(), + "vault must hold the keys before the blob is rewritten" + ); + // And the in-memory blob being persisted is already InVault-only. + assert!( + migrated + .private_keys + .private_keys + .values() + .all(|(_, d)| !matches!( + d, + PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_) + )), + "persisted blob must carry no plaintext" + ); + persisted = true; + Ok(()) + }); + + assert_eq!(outcome, KeystoreMigration::Migrated(2)); + assert!(persisted, "persist closure ran"); + // Both plaintext keys are in the vault and equal the originals. + assert_eq!( + *view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .unwrap() + .unwrap(), + high + ); + assert_eq!( + *view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 2) + .unwrap() + .unwrap(), + medium + ); + // The wallet-derived key (key_id 3) was never plaintext → not stored. + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .unwrap() + .is_none(), + "AtWalletDerivationPath key must be untouched (not vaulted)" + ); + // KeyStorage now has zero Clear/AlwaysClear; the derived key remains. + let mut derived = 0; + for (_, d) in qi.private_keys.private_keys.values() { + match d { + PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_) => { + panic!("plaintext survived migration") + } + PrivateKeyData::AtWalletDerivationPath(_) => derived += 1, + _ => {} + } + } + assert_eq!(derived, 1, "wallet-derived key preserved"); + + // Idempotent: a second run finds nothing to migrate. + assert_eq!( + migrate_keystore_to_vault(&store, &id, &mut qi, |_| Ok(())), + KeystoreMigration::Nothing + ); + } + + /// QA-005 — write-fault no-loss ordering. With the vault made unwritable so + /// `store_all` fails, the migration restores the resident plaintext, does + /// NOT call persist, and reports `VaultWriteFailed` — keys are never lost on + /// a mid-write fault (the write half CRASH-01's read half does not cover). + #[cfg(unix)] + #[test] + fn qa_005_vault_write_fault_leaves_keystore_intact_and_skips_persist() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let store = fresh_vault(dir.path()); + let id = id(0x22); + let high = [0xCC; 32]; + let medium = [0xDD; 32]; + let mut qi = qi_with_plaintext_and_derived(high, medium); + let before = qi.private_keys.clone(); + + // Make the vault's parent dir read-only so the atomic rename-replace + // `set` fails. (The file backend rewrites the whole file on set.) + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)) + .expect("chmod ro"); + + let mut persisted = false; + let outcome = migrate_keystore_to_vault(&store, &id, &mut qi, |_| { + persisted = true; + Ok(()) + }); + + // Restore perms so tempdir cleanup works. + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).ok(); + + assert_eq!(outcome, KeystoreMigration::VaultWriteFailed); + assert!(!persisted, "persist must NOT run when the vault write failed"); + assert_eq!( + qi.private_keys, before, + "the resident plaintext keystore must be restored on vault failure" + ); + } + + /// QA-003 — `clear_identity_vault_keys` removes the deleted identity's vault + /// keys AND leaves other identities' keys untouched (isolation), via the + /// public delete entry point. Builds a real `AppContext`-free vault and + /// drives the free `IdentityKeyView` the deletion uses. + #[test] + fn qa_003_identity_key_deletion_is_scoped_and_isolated() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_vault(dir.path()); + let victim = id(0x33); + let bystander = id(0x44); + + // Both identities have a vaulted key under the same (target, key_id). + IdentityKeyView::new(&store, victim) + .store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x01; 32]) + .unwrap(); + IdentityKeyView::new(&store, bystander) + .store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x02; 32]) + .unwrap(); + + // Delete the victim's keys the way clear_identity_vault_keys does: + // enumerate the keystore's (target,key_id) set and delete_all. + let mut ks = KeyStorage::default(); + let pv = PlatformVersion::latest(); + let pk = IdentityPublicKey::random_key(0, Some(0), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 0), + (QualifiedIdentityPublicKey::from(pk), PrivateKeyData::InVault), + ); + IdentityKeyView::new(&store, victim) + .delete_all(ks.keys_set()) + .unwrap(); + + assert!( + IdentityKeyView::new(&store, victim) + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap() + .is_none(), + "victim's vault key must be gone" + ); + assert_eq!( + *IdentityKeyView::new(&store, bystander) + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap() + .unwrap(), + [0x02; 32], + "a different identity's vault key must be untouched (isolation)" + ); + } } From 99b592672b671f8e5ecb7a898cc0515371e6bee3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:24:35 +0200 Subject: [PATCH 17/24] test(wallet-lifecycle): assert lazy-migration secret post-conditions (QA-004) The protected-wallet-unlock test asserted only upstream registration. Add the secret post-conditions the lazy migration is actually for: after handle_wallet_unlocked the raw seed is written and equals the true 64-byte seed, the legacy envelope.v1 is deleted, WalletMeta.uses_password flipped false, and a SECOND resolve through a never-prompt chokepoint over the now-raw vault returns the seed with zero prompts (the migrated wallet is permanently prompt-free). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/context/wallet_lifecycle.rs | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 39e2c7724..7123569f6 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -3264,6 +3264,46 @@ mod tests { "exactly one wallet must be watched after the unlock reconciliation" ); + // QA-004 — lazy-migration secret post-conditions. The unlock decrypted + // the legacy envelope and re-stored the seed raw, vault-first. + let store = ctx.secret_store(); + let seed_view = WalletSeedView::new(&store); + let raw = seed_view + .get_raw(&seed_hash) + .expect("raw read") + .expect("the seed must be re-stored raw after the migrating unlock"); + assert_eq!(&*raw, &seed, "raw seed must equal the true 64-byte seed"); + assert!( + seed_view + .legacy_envelope_get(&seed_hash) + .expect("legacy read") + .is_none(), + "the legacy envelope must be deleted after migration" + ); + // The sidecar password flag is flipped, so the next unlock is prompt-free. + let meta = WalletMetaView::new(&ctx.app_kv()) + .get(Network::Testnet, &seed_hash) + .expect("wallet meta present"); + assert!( + !meta.uses_password, + "WalletMeta.uses_password must flip false after migration" + ); + + // A SECOND secret resolve for this seed is prompt-free: a never-prompt + // chokepoint over the now-raw vault resolves the true seed with zero asks. + use crate::wallet_backend::secret_prompt::test_support::TestPrompt; + use crate::wallet_backend::{SecretAccess, SecretScope}; + let never = std::sync::Arc::new(TestPrompt::never()); + let sa = SecretAccess::new(ctx.secret_store(), never.clone(), Network::Testnet); + let resolved = sa + .with_secret(&SecretScope::HdSeed { seed_hash }, |pt| { + Ok(pt.expose_hd_seed().copied()) + }) + .await + .expect("second resolve is prompt-free"); + assert_eq!(resolved, Some(seed), "prompt-free resolve returns the seed"); + assert_eq!(never.ask_count(), 0, "the second unlock never prompts"); + backend.shutdown().await; } From a72717963f81b115303fc26c2ecd926ff510afc6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:27:32 +0200 Subject: [PATCH 18/24] test(backend-e2e): TS-SIGN-E2E-01 InVault identity signs + broadcasts (QA-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New #[ignore] backend-e2e test: migrate the shared identity's plaintext signing keys to the vault (PrivateKeyData::InVault, exactly as the eager load-path migration does), assert residency (zero Clear/AlwaysClear remain), wire the chokepoint, then build + sign + broadcast an IdentityUpdateTransition. Signing runs through the async QualifiedIdentity Signer → resolve_private_key_bytes → with_secret(IdentityKey{..}) — the JIT free-rider path. A successful broadcast + the new key appearing on Platform proves the InVault MASTER key signed live without ever being resident. Requires E2E_WALLET_MNEMONIC + live DAPI/SPV; run command + RUST_MIN_STACK in the header. Compiles + registered in main.rs; left #[ignore] for a manual/live run during QA. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- tests/backend-e2e/identity_in_vault_sign.rs | 180 ++++++++++++++++++++ tests/backend-e2e/main.rs | 1 + 2 files changed, 181 insertions(+) create mode 100644 tests/backend-e2e/identity_in_vault_sign.rs diff --git a/tests/backend-e2e/identity_in_vault_sign.rs b/tests/backend-e2e/identity_in_vault_sign.rs new file mode 100644 index 000000000..0cace5d59 --- /dev/null +++ b/tests/backend-e2e/identity_in_vault_sign.rs @@ -0,0 +1,180 @@ +//! TS-SIGN-E2E-01 — broadcast a state transition signed by a MIGRATED +//! `InVault` identity key, proving the per-use JIT free-rider path end-to-end. +//! +//! The shared identity's signing keys are migrated to the vault as raw bytes +//! (`PrivateKeyData::InVault`), exactly as the eager load-path migration does, +//! then an IdentityUpdateTransition is built + signed + broadcast. Signing +//! routes through the async `QualifiedIdentity` `Signer` → +//! `resolve_private_key_bytes` → `with_secret(SecretScope::IdentityKey{..})`, +//! which fetches the raw key from the vault per-use (prompt-free). A successful +//! broadcast proves the key was never resident yet still signed live. +//! +//! `#[ignore]` — requires `E2E_WALLET_MNEMONIC` + live DAPI/SPV. Run with: +//! ```bash +//! RUST_MIN_STACK=16777216 cargo test --test backend-e2e --all-features -- \ +//! --ignored --nocapture ts_sign_e2e_01_in_vault_identity_signs_and_broadcasts +//! ``` + +use crate::framework::fixtures::shared_identity; +use crate::framework::harness::ctx; +use crate::framework::task_runner::run_task_with_nonce_retry; +use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::PrivateKeyData; +use dash_evo_tool::wallet_backend::IdentityKeyView; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; +use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::prelude::UserFeeIncrease; +use dash_sdk::dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dash_sdk::dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; +use dash_sdk::platform::{Fetch, IdentityPublicKey}; + +/// TS-SIGN-E2E-01. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn ts_sign_e2e_01_in_vault_identity_signs_and_broadcasts() { + let ctx = ctx().await; + let si = shared_identity().await; + + let platform_version = ctx.app_context.platform_version(); + let identity_id = si.qualified_identity.identity.id(); + + // Fetch the live identity (latest keys + revision). + let sdk = ctx.app_context.sdk(); + let mut identity = dash_sdk::platform::Identity::fetch_by_identifier(&sdk, identity_id) + .await + .expect("fetch identity") + .expect("identity present"); + + // Build the qualified identity and MIGRATE its plaintext signing keys into + // the vault as InVault — exactly what the eager load-path migration does. + let mut qi = si.qualified_identity.clone(); + qi.identity = identity.clone(); + + let taken = qi.private_keys.take_plaintext_for_vault(); + assert!( + !taken.is_empty(), + "the shared identity must have carried plaintext signing keys to migrate" + ); + IdentityKeyView::new(&ctx.app_context.secret_store(), identity_id.to_buffer()) + .store_all(&taken) + .expect("store identity keys raw in the vault"); + + // Residency: after migration the keystore must hold ONLY InVault for the + // migrated keys — no resident plaintext. + assert!( + qi.private_keys + .private_keys + .values() + .all(|(_, d)| !matches!( + d, + PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_) + )), + "no plaintext identity key may remain resident after migration" + ); + assert!( + qi.private_keys + .private_keys + .values() + .any(|(_, d)| matches!(d, PrivateKeyData::InVault)), + "migrated keys must be InVault placeholders" + ); + + // Wire the chokepoint so the resolver can fetch the raw key per-use. + qi.secret_access = Some(ctx.app_context.wallet_backend().unwrap().secret_access()); + + // Build a new key to add, and sign the IdentityUpdate with the (now InVault) + // MASTER key via the JIT free-rider path. + let new_private_key_bytes: [u8; 32] = rand::random(); + let new_public_key_data = { + use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; + use dash_sdk::dpp::dashcore::PrivateKey; + let secp = Secp256k1::new(); + let secret_key = + dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&new_private_key_bytes) + .expect("valid secret"); + PrivateKey::new(secret_key, Network::Testnet) + .public_key(&secp) + .to_bytes() + }; + let mut new_ipk = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: new_public_key_data.into(), + disabled_at: None, + }); + new_ipk.set_id(identity.get_public_key_max_id() + 1); + identity.bump_revision(); + + let nonce = sdk + .get_identity_nonce(identity_id, true, None) + .await + .expect("fetch nonce"); + let master_key_id = identity + .public_keys() + .values() + .find(|k| { + k.purpose() == Purpose::AUTHENTICATION && k.security_level() == SecurityLevel::MASTER + }) + .expect("identity has a MASTER AUTHENTICATION key") + .id(); + + // The new key's plaintext is registered so the ST can sign the key-add proof + // of possession; the MASTER signer key is the InVault one we just migrated. + qi.private_keys.insert_non_encrypted( + ( + dash_evo_tool::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + new_ipk.id(), + ), + ( + dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey::from(new_ipk.clone()), + new_private_key_bytes, + ), + ); + + let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( + &identity, + &master_key_id, + vec![new_ipk.clone()], + vec![], + nonce, + UserFeeIncrease::default(), + &qi, + platform_version, + None, + ) + .await + .expect("build + sign IdentityUpdateTransition via the InVault JIT path"); + + let result = run_task_with_nonce_retry( + &ctx.app_context, + BackendTask::BroadcastStateTransition(state_transition), + ) + .await + .expect("broadcast should succeed"); + assert!( + matches!(result, BackendTaskSuccessResult::BroadcastedStateTransition), + "expected BroadcastedStateTransition, got {result:?}" + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let fetched = dash_sdk::platform::Identity::fetch_by_identifier(&sdk, identity_id) + .await + .expect("re-fetch identity") + .expect("identity present after broadcast"); + assert!( + fetched + .public_keys() + .values() + .any(|k| k.data() == new_ipk.data()), + "the new key must be visible on Platform — the InVault MASTER key signed the ST" + ); +} diff --git a/tests/backend-e2e/main.rs b/tests/backend-e2e/main.rs index f5d4a901c..21fd66b9a 100644 --- a/tests/backend-e2e/main.rs +++ b/tests/backend-e2e/main.rs @@ -29,6 +29,7 @@ mod spv_reconnect; mod core_tasks; mod dashpay_tasks; mod event_bridge_live; +mod identity_in_vault_sign; mod identity_tasks; mod shielded_tasks; mod token_tasks; From 73c189d11adcb7127efcffb9581841b71e7d5eec Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:31:25 +0200 Subject: [PATCH 19/24] refactor(wallet-backend): zeroize migration source, flavor identity-key errors, lift signed-message helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROJ-004 (security): take_plaintext_for_vault now zeroizes the resident Clear/AlwaysClear array BEFORE the InVault overwrite drops it — de-residenting the key is the function's whole purpose, so it must wipe the source, not just the moved-out copy. PROJ-005: IdentityKeyView::store/get/delete now map the generic seam error to the identity-flavored TaskError::IdentityKeyVault (previously a producerless variant), so an identity-key vault failure surfaces with identity-specific banner copy. Wrong-length stays SecretDecryptFailed. QA-DEDUP-01: lift dash_signed_message (the recoverable-envelope builder) from sign_message_with_key.rs to backend_task/wallet/mod.rs as pub(crate); both the wallet-key and identity-key signers now call it instead of two drifting copies. The recovery-header round-trip tests move alongside the shared helper. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/backend_task/wallet/mod.rs | 58 +++++++++++++++++++ .../wallet/sign_message_with_identity_key.rs | 12 ++-- .../wallet/sign_message_with_key.rs | 53 +---------------- .../encrypted_key_storage.rs | 9 ++- src/wallet_backend/identity_key_store.rs | 21 ++++++- 5 files changed, 91 insertions(+), 62 deletions(-) diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index 01759082e..cdad69585 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -16,12 +16,32 @@ use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::Credits; use dash_sdk::dpp::dashcore::OutPoint; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; +use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; use dash_sdk::dpp::identity::core_script::CoreScript; use dash_sdk::dpp::identity::{KeyID, KeyType}; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::platform::Identifier; use std::collections::BTreeMap; +/// Build the Base64-encoded Dash signed-message envelope for `message` signed +/// with `secret_key`. The envelope is a recoverable signature: a header byte +/// (`27 + recId`, `+4` when `compressed`) followed by the 64-byte signature, so +/// a verifier can recover the signer's public key from the signature alone. +/// Shared by the wallet-key and identity-key message-signing tasks. +pub(crate) fn dash_signed_message( + message: &str, + secret_key: &SecretKey, + compressed: bool, +) -> String { + let secp = Secp256k1::new(); + let message_hash = signed_msg_hash(message); + let digest = Message::from_digest(*message_hash.as_byte_array()); + let recoverable = secp.sign_ecdsa_recoverable(&digest, secret_key); + MessageSignature::new(recoverable, compressed).to_base64() +} + #[derive(Debug, Clone, PartialEq)] pub enum WalletTask { GenerateReceiveAddress { @@ -147,3 +167,41 @@ pub enum WalletTask { fee_deduct_from_output: bool, }, } + +#[cfg(test)] +mod tests { + use super::dash_signed_message; + use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; + + /// The shared signed-message envelope round-trips: the signer's public key + /// recovers from the produced signature for both compression flags. A + /// hardcoded recovery header would fail ~50% of the time here. Both the + /// wallet-key and identity-key signers call this one helper. + fn assert_recovers(compressed: bool) { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_byte_array(&[0x42u8; 32]).expect("valid secret"); + let expected_pubkey = PublicKey::from_secret_key(&secp, &secret_key); + let message = "Bilby was here"; + + let base64 = dash_signed_message(message, &secret_key, compressed); + let parsed = MessageSignature::from_base64(&base64).expect("valid envelope"); + assert_eq!(parsed.compressed, compressed); + + let recovered = parsed + .recover_pubkey(&secp, signed_msg_hash(message)) + .expect("recovers a public key"); + assert_eq!(recovered.inner, expected_pubkey); + assert_eq!(recovered.compressed, compressed); + } + + #[test] + fn recovers_signer_pubkey_compressed() { + assert_recovers(true); + } + + #[test] + fn recovers_signer_pubkey_uncompressed() { + assert_recovers(false); + } +} diff --git a/src/backend_task/wallet/sign_message_with_identity_key.rs b/src/backend_task/wallet/sign_message_with_identity_key.rs index a9491ea9d..d3198c74f 100644 --- a/src/backend_task/wallet/sign_message_with_identity_key.rs +++ b/src/backend_task/wallet/sign_message_with_identity_key.rs @@ -4,11 +4,10 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; +use crate::backend_task::wallet::dash_signed_message; use crate::context::AppContext; use crate::model::qualified_identity::PrivateKeyTarget; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; -use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; +use dash_sdk::dpp::dashcore::secp256k1::SecretKey; use dash_sdk::dpp::identity::{KeyID, KeyType}; use dash_sdk::platform::Identifier; use std::sync::Arc; @@ -49,11 +48,8 @@ impl AppContext { tracing::warn!(error = %detail, "Identity-key sign secret construction failed"); TaskError::WalletMessageSigningFailed })?; - let secp = Secp256k1::new(); - let digest = - Message::from_digest(*signed_msg_hash(message.as_str()).as_byte_array()); - let recoverable = secp.sign_ecdsa_recoverable(&digest, &secret_key); - Ok(MessageSignature::new(recoverable, true).to_base64()) + // Identity keys are compressed by convention. + Ok(dash_signed_message(message.as_str(), &secret_key, true)) }) .await?; diff --git a/src/backend_task/wallet/sign_message_with_key.rs b/src/backend_task/wallet/sign_message_with_key.rs index 6184faad3..752f47809 100644 --- a/src/backend_task/wallet/sign_message_with_key.rs +++ b/src/backend_task/wallet/sign_message_with_key.rs @@ -3,27 +3,14 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; +use crate::backend_task::wallet::dash_signed_message; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; -use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; +use dash_sdk::dpp::dashcore::secp256k1::SecretKey; use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use std::sync::Arc; -/// Build the Base64-encoded Dash signed-message envelope for `message` signed -/// with `secret_key`. The envelope is a recoverable signature: a header byte -/// (`27 + recId`, `+4` when `compressed`) followed by the 64-byte signature, so -/// a verifier can recover the signer's public key from the signature alone. -fn dash_signed_message(message: &str, secret_key: &SecretKey, compressed: bool) -> String { - let secp = Secp256k1::new(); - let message_hash = signed_msg_hash(message); - let digest = Message::from_digest(*message_hash.as_byte_array()); - let recoverable = secp.sign_ecdsa_recoverable(&digest, secret_key); - MessageSignature::new(recoverable, compressed).to_base64() -} - impl AppContext { /// Sign a message with a wallet-derived key at `derivation_path`. /// @@ -96,39 +83,3 @@ impl AppContext { } } -#[cfg(test)] -mod tests { - use super::*; - use dash_sdk::dpp::dashcore::secp256k1::PublicKey; - use dash_sdk::dpp::dashcore::sign_message::signed_msg_hash; - - /// The envelope round-trips: the signer's public key recovers from the - /// produced signature for both compression flags. A hardcoded recovery - /// header would fail ~50% of the time here. - fn assert_recovers(compressed: bool) { - let secp = Secp256k1::new(); - let secret_key = SecretKey::from_byte_array(&[0x42u8; 32]).expect("valid secret"); - let expected_pubkey = PublicKey::from_secret_key(&secp, &secret_key); - let message = "Bilby was here"; - - let base64 = dash_signed_message(message, &secret_key, compressed); - let parsed = MessageSignature::from_base64(&base64).expect("valid envelope"); - assert_eq!(parsed.compressed, compressed); - - let recovered = parsed - .recover_pubkey(&secp, signed_msg_hash(message)) - .expect("recovers a public key"); - assert_eq!(recovered.inner, expected_pubkey); - assert_eq!(recovered.compressed, compressed); - } - - #[test] - fn recovers_signer_pubkey_compressed() { - assert_recovers(true); - } - - #[test] - fn recovers_signer_pubkey_uncompressed() { - assert_recovers(false); - } -} diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index f3c715e3b..2ef4aced3 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -560,10 +560,17 @@ impl KeyStorage { /// vault-backed / encrypted keys are left untouched — they were never /// plaintext-at-rest. pub fn take_plaintext_for_vault(&mut self) -> Vec { + use zeroize::Zeroize; let mut out = Vec::new(); for (map_key, (_pub_key, data)) in self.private_keys.iter_mut() { let raw = match data { - PrivateKeyData::Clear(bytes) | PrivateKeyData::AlwaysClear(bytes) => *bytes, + PrivateKeyData::Clear(bytes) | PrivateKeyData::AlwaysClear(bytes) => { + let raw = *bytes; + // Wipe the resident array before the `InVault` overwrite + // drops it — de-residenting the key is this fn's whole job. + bytes.zeroize(); + raw + } _ => continue, }; out.push((map_key.clone(), Zeroizing::new(raw))); diff --git a/src/wallet_backend/identity_key_store.rs b/src/wallet_backend/identity_key_store.rs index a2e7954d7..076266dc8 100644 --- a/src/wallet_backend/identity_key_store.rs +++ b/src/wallet_backend/identity_key_store.rs @@ -59,6 +59,7 @@ impl<'a> IdentityKeyView<'a> { let label = SecretScope::identity_key_label(target, key_id); self.seam() .put_secret(&self.scope(), &label, &SecretBytes::from_slice(key)) + .map_err(identity_flavored) } /// Store every `(target, key_id) → raw 32 bytes` pair. Used by the @@ -79,7 +80,11 @@ impl<'a> IdentityKeyView<'a> { key_id: KeyID, ) -> Result>, TaskError> { let label = SecretScope::identity_key_label(target, key_id); - let Some(bytes) = self.seam().get_secret(&self.scope(), &label)? else { + let Some(bytes) = self + .seam() + .get_secret(&self.scope(), &label) + .map_err(identity_flavored)? + else { return Ok(None); }; let key: [u8; 32] = bytes.expose_secret().try_into().map_err(|_| { @@ -96,7 +101,9 @@ impl<'a> IdentityKeyView<'a> { /// Idempotent delete of one identity key. pub fn delete(&self, target: &PrivateKeyTarget, key_id: KeyID) -> Result<(), TaskError> { let label = SecretScope::identity_key_label(target, key_id); - self.seam().delete_secret(&self.scope(), &label) + self.seam() + .delete_secret(&self.scope(), &label) + .map_err(identity_flavored) } /// Delete every `(target, key_id)` listed. Idempotent. Used on identity @@ -112,6 +119,16 @@ impl<'a> IdentityKeyView<'a> { } } +/// Re-flavor a generic seam error as the identity-key-domain variant so a vault +/// failure on an identity key surfaces with identity-specific banner copy. Any +/// non-`SecretSeam` error passes through unchanged. +fn identity_flavored(e: TaskError) -> TaskError { + match e { + TaskError::SecretSeam { source } => TaskError::IdentityKeyVault { source }, + other => other, + } +} + #[cfg(test)] mod tests { use super::*; From 66c32041dab376373b7f20220b8e8ab45989ab9a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:34:55 +0200 Subject: [PATCH 20/24] test(secret-seam): TS-INV-03 audit guard + TS-NOLEAK-02 sidecar no-leak (SEC-001/002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEC-001 (TS-INV-03): source-text audit over the changed secret-path modules — no Serialize/Encode struct may name a plaintext-key field (SecretBytes, Zeroizing<[u8, [u8;32], [u8;64]). Catches the bare-Vec/array plaintext bypass the compile_fail doctests can't (they only catch an embedded SecretBytes). The module list mirrors the blast-radius table; ciphertext fields are deliberately not flagged. Passes — the invariant holds today and now has a regression guard. SEC-002 (TS-NOLEAK-02): assert the encoded WalletMeta + ImportedKey sidecar blobs contain neither secret (hex AND decimal-array via the shared assert_no_leak_bytes), and that the ImportedKey's PUBLIC key IS present (locked render needs it). Canary coverage — the sidecars structurally hold no secret. Plus a clarifying "// no secret to (de)crypt" note at delete_secret instead of an encryption TODO. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/model/single_key.rs | 46 ++++++++++++++++++ src/model/wallet/meta.rs | 26 ++++++++++ src/wallet_backend/secret_seam.rs | 79 +++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) diff --git a/src/model/single_key.rs b/src/model/single_key.rs index db6007611..e505aab58 100644 --- a/src/model/single_key.rs +++ b/src/model/single_key.rs @@ -97,3 +97,49 @@ impl From for ImportedKey { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::leak_test_support::{assert_no_leak_bytes, distinctive_secret_32}; + + /// TS-NOLEAK-02 (ImportedKey) — the encoded sidecar blob carries NO private + /// key, and the PUBLIC key (needed for locked render) IS present. The + /// sidecar holds only the public key, never the secret; this is the canary. + #[test] + fn ts_noleak_02_imported_key_blob_has_no_private_key_but_has_public() { + let private = distinctive_secret_32(); + // A distinctive PUBLIC-key placeholder we DO expect to find. + let public = vec![0x02u8; 33]; + let imported = ImportedKey { + address: "yTestAddr".into(), + alias: Some("savings".into()), + network: Network::Testnet, + has_passphrase: true, + passphrase_hint: Some("hint".into()), + public_key_bytes: public.clone(), + }; + let blob = + bincode::serde::encode_to_vec(&imported, bincode::config::standard()).expect("encode"); + + // The private key appears in neither hex nor decimal-array form. + let rendered = format!("{blob:?}"); + assert_no_leak_bytes(&rendered, &private, "ImportedKey sidecar blob"); + + // The public key IS present (locked render reads it back). + let decimal = format!( + "[{}]", + public + .iter() + .map(|b| b.to_string()) + .collect::>() + .join(", ") + ); + // The 33-byte pubkey is embedded as a Vec — its bytes are in the blob. + let blob_contains_pubkey = blob.windows(public.len()).any(|w| w == public.as_slice()); + assert!( + blob_contains_pubkey || rendered.contains(&decimal), + "the public key must be present in the sidecar for locked render" + ); + } +} diff --git a/src/model/wallet/meta.rs b/src/model/wallet/meta.rs index db122a75c..7f9eb4b89 100644 --- a/src/model/wallet/meta.rs +++ b/src/model/wallet/meta.rs @@ -199,4 +199,30 @@ mod tests { bincode::serde::decode_from_slice(&new_blob, cfg).expect("decode v2"); assert_eq!(decoded, v2); } + + /// TS-NOLEAK-02 (WalletMeta) — the encoded sidecar blob carries NO secret. + /// `WalletMeta` structurally cannot hold a key (no secret field); this is + /// canary coverage that a future field never smuggles one in. Asserted in + /// both hex and decimal-array form via the shared helper. + #[test] + fn ts_noleak_02_wallet_meta_blob_has_no_secret() { + use crate::wallet_backend::leak_test_support::{ + assert_no_leak_bytes, distinctive_secret_64, + }; + // A distinctive seed that must NOT appear in the sidecar bytes. + let secret = distinctive_secret_64(); + let meta = WalletMeta { + alias: "paycheque".into(), + is_main: true, + core_wallet_name: Some("dev".into()), + // The xpub is PUBLIC material, not the seed — unrelated bytes. + xpub_encoded: vec![0xCD; 78], + uses_password: true, + password_hint: Some("hint".into()), + }; + let blob = + bincode::serde::encode_to_vec(&meta, bincode::config::standard()).expect("encode"); + let rendered = format!("{blob:?}"); + assert_no_leak_bytes(&rendered, &secret, "WalletMeta sidecar blob"); + } } diff --git a/src/wallet_backend/secret_seam.rs b/src/wallet_backend/secret_seam.rs index a2fbcf41e..98c5265b4 100644 --- a/src/wallet_backend/secret_seam.rs +++ b/src/wallet_backend/secret_seam.rs @@ -93,6 +93,8 @@ impl<'a> SecretSeam<'a> { } /// Idempotent delete of `(scope, label)`. A missing entry is `Ok(())`. + // No `TODO(per-secret-encryption)` here — delete is metadata-free, there is + // no secret to (de)crypt. pub fn delete_secret(&self, scope: &SecretWalletId, label: &str) -> Result<(), TaskError> { self.secret_store.delete(scope, label).map_err(map_err) } @@ -297,4 +299,81 @@ mod tests { "seam on-disk vault", ); } + + /// TS-INV-03 — source-text audit over the changed secret-path modules: no + /// `#[derive(...Serialize...)]` / `Encode` struct may name a plaintext-key + /// field. Catches the bare-`Vec`/`[u8; 32]` plaintext bypass the + /// `compile_fail` doctests (which only catch an embedded `SecretBytes`) + /// cannot. The module list must track the blast-radius table — a stale list + /// silently shrinks the surface, itself a finding. + #[test] + fn ts_inv_03_no_serializable_struct_embeds_a_plaintext_field() { + // Files under the secret-path blast radius. + const MODULES: &[&str] = &[ + "src/wallet_backend/secret_seam.rs", + "src/wallet_backend/secret_access.rs", + "src/wallet_backend/identity_key_store.rs", + "src/wallet_backend/wallet_seed_store.rs", + "src/wallet_backend/single_key.rs", + "src/wallet_backend/single_key_entry.rs", + "src/model/qualified_identity/encrypted_key_storage.rs", + "src/model/wallet/meta.rs", + "src/model/single_key.rs", + "src/model/wallet/seed_envelope.rs", + ]; + // Field-shape needles that name plaintext key/seed material by type. + // (`ciphertext`/`encrypted_seed`/`encrypted_private_key` are NOT here — + // they hold AES-GCM ciphertext or migration-reader bytes, not plaintext; + // the no-serialization invariant is about embedding live plaintext.) + const PLAINTEXT_NEEDLES: &[&str] = &["SecretBytes", "Zeroizing<[u8", ": [u8; 32]", ": [u8; 64]"]; + + let manifest = env!("CARGO_MANIFEST_DIR"); + let mut offenders = Vec::new(); + for rel in MODULES { + let path = std::path::Path::new(manifest).join(rel); + let src = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("audit must read {rel}: {e} (stale module list?)")); + + // Track whether the most recent derive line opted into a serializer. + let mut in_serializable_struct = false; + let mut brace_depth_at_struct: Option = None; + let mut depth = 0usize; + for line in src.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("#[derive(") + && (trimmed.contains("Serialize") || trimmed.contains("Encode")) + { + in_serializable_struct = true; + brace_depth_at_struct = None; + continue; + } + if in_serializable_struct && brace_depth_at_struct.is_none() { + // The struct opener for the pending derive. + if line.contains('{') { + brace_depth_at_struct = Some(depth); + } + } + // Inside the serializable struct body, look for plaintext fields. + if in_serializable_struct + && brace_depth_at_struct.is_some() + && PLAINTEXT_NEEDLES.iter().any(|n| line.contains(n)) + { + offenders.push(format!("{rel}: {}", line.trim())); + } + depth += line.matches('{').count(); + depth = depth.saturating_sub(line.matches('}').count()); + if let Some(start) = brace_depth_at_struct + && depth <= start + && line.contains('}') + { + in_serializable_struct = false; + brace_depth_at_struct = None; + } + } + } + assert!( + offenders.is_empty(), + "a Serialize/Encode struct names a plaintext-key field (no-serialization invariant): {offenders:#?}", + ); + } } From 906a2f1813667a0a43fd036f633328d1df82612b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:38:14 +0200 Subject: [PATCH 21/24] test(kittest): disclosure-banner copy coverage (QA-007/Diziet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the interim at-rest disclosure copy into pure pub fns (wallet_migration_notice / single_key_migration_notice) + pub INTERIM_AT_REST_DETAILS, re-exported from context, so the exact copy is testable without an AppState and i18n-extractable. Both callsites now use them. New tests/kittest/disclosure_banner.rs (QA-007): Copy A and Copy B each render as Warning banners naming the wallet/key, the ⚠ icon shows (not color-only), the two copies are DISTINCT (so set_global's text-dedup keeps both when a wallet and a key migrate in one session), and all copy (A/B/D) is jargon-free (no AES/vault/seam/encryption/0600). 4 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/context/mod.rs | 4 ++ src/context/wallet_lifecycle.rs | 35 +++++++--- tests/kittest/disclosure_banner.rs | 105 +++++++++++++++++++++++++++++ tests/kittest/main.rs | 1 + 4 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 tests/kittest/disclosure_banner.rs diff --git a/src/context/mod.rs b/src/context/mod.rs index cd8710cd0..fb408452f 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -7,6 +7,10 @@ mod platform_address_db; mod settings_db; mod wallet_lifecycle; +pub use wallet_lifecycle::{ + INTERIM_AT_REST_DETAILS, single_key_migration_notice, wallet_migration_notice, +}; + use crate::app_dir::core_cookie_path; use crate::backend_task::error::{TaskError, is_rpc_connection_error}; use crate::config::{Config, NetworkConfig}; diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 7123569f6..fa8a1cc65 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -20,7 +20,27 @@ const AUTH_PUBKEY_WARM_KEY_COUNT: u32 = 12; /// Copy D — the shared, opt-in technical detail attached to the one-time /// at-rest disclosure notice (jargon-free per the persona spec). Surfaced via /// `with_details`, so it lives in the collapsible panel and the log. -const INTERIM_AT_REST_DETAILS: &str = "This wallet's secrets are now stored in a shared protected location on this device, guarded by your computer's account and file permissions rather than by your wallet password. This is a temporary step while a stronger, built-in protection is being finished. Your keys never leave this device. To keep this wallet extra safe in the meantime, make sure your computer account is password-protected and not shared."; +pub const INTERIM_AT_REST_DETAILS: &str = "This wallet's secrets are now stored in a shared protected location on this device, guarded by your computer's account and file permissions rather than by your wallet password. This is a temporary step while a stronger, built-in protection is being finished. Your keys never leave this device. To keep this wallet extra safe in the meantime, make sure your computer account is password-protected and not shared."; + +/// Copy A — the one-time disclosure shown when a password-protected HD wallet +/// finishes its lazy migration. `wallet` is the wallet alias (or a default). +/// Distinct text from [`single_key_migration_notice`] so `MessageBanner`'s +/// text-dedup never collapses the two when both migrate in one session. +pub fn wallet_migration_notice(wallet: &str) -> String { + let wallet = if wallet.is_empty() { "Your wallet" } else { wallet }; + format!( + "\"{wallet}\" no longer needs its password to open. Your wallet stays on this device, protected by your computer's account. Full password protection will return in a future update." + ) +} + +/// Copy B — the one-time disclosure shown when a protected imported key +/// finishes its lazy migration. `key` is the key's user-facing label. Distinct +/// text from [`wallet_migration_notice`] (see that fn's note). +pub fn single_key_migration_notice(key: &str) -> String { + format!( + "The imported key \"{key}\" no longer needs its passphrase to use. It stays on this device, protected by your computer's account. Full passphrase protection will return in a future update." + ) +} /// The upstream `dash-spv` `DiskStorageManager` chain-cache entries under the /// per-network SPV directory. Each is a subfolder except `peers.dat`. The @@ -287,9 +307,7 @@ impl AppContext { use crate::ui::MessageType; use crate::ui::components::message_banner::MessageBanner; - let message = format!( - "The imported key \"{label}\" no longer needs its passphrase to use. It stays on this device, protected by your computer's account. Full passphrase protection will return in a future update." - ); + let message = single_key_migration_notice(label); MessageBanner::set_global(self.egui_ctx(), &message, MessageType::Warning) .with_details(INTERIM_AT_REST_DETAILS); } @@ -1061,13 +1079,8 @@ impl AppContext { } } - // Copy A (wallet) — Warning so it does not auto-dismiss before read. - // Distinct text from the imported-key notice so `set_global`'s dedup - // does not collapse them when both migrate in one session. - let wallet = alias.filter(|a| !a.is_empty()).unwrap_or("Your wallet"); - let message = format!( - "\"{wallet}\" no longer needs its password to open. Your wallet stays on this device, protected by your computer's account. Full password protection will return in a future update." - ); + // Copy A — Warning so it does not auto-dismiss before read. + let message = wallet_migration_notice(alias.unwrap_or_default()); MessageBanner::set_global(self.egui_ctx(), &message, MessageType::Warning) .with_details(INTERIM_AT_REST_DETAILS); } diff --git a/tests/kittest/disclosure_banner.rs b/tests/kittest/disclosure_banner.rs new file mode 100644 index 000000000..833e06005 --- /dev/null +++ b/tests/kittest/disclosure_banner.rs @@ -0,0 +1,105 @@ +//! kittest coverage for the secret-storage-seam interim at-rest disclosure +//! (Diziet §Item 1/2/3). Drives the public `MessageBanner` surface against the +//! exact copy the app emits at a migrating unlock, so a wording/type regression +//! fails here without a full `AppState`. + +use dash_evo_tool::context::{ + INTERIM_AT_REST_DETAILS, single_key_migration_notice, wallet_migration_notice, +}; +use dash_evo_tool::ui::MessageType; +use dash_evo_tool::ui::components::MessageBanner; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// QA-007 — Copy A (wallet) renders as a Warning banner with the wallet alias, +/// and the ⚠ icon is present (color is not the only indicator). Warning, not +/// Info, so it does not auto-dismiss on the short timer before it is read. +#[test] +fn qa_007_wallet_migration_notice_renders_as_warning() { + let copy = wallet_migration_notice("paycheque"); + let copy_for_ui = copy.clone(); + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 220.0)) + .build_ui(move |ui| { + MessageBanner::set_global(ui.ctx(), ©_for_ui, MessageType::Warning) + .with_details(INTERIM_AT_REST_DETAILS); + MessageBanner::show_global(ui); + }); + harness.run(); + assert!( + harness.query_by_label(©).is_some(), + "Copy A must render verbatim", + ); + assert!( + copy.contains("paycheque"), + "Copy A names the wallet alias", + ); + // Warning glyph present. + assert!( + harness.query_by_label("\u{26A0}").is_some(), + "Warning banner must show the ⚠ icon", + ); +} + +/// QA-007 — Copy B (imported key) renders and names the key label. +#[test] +fn qa_007_single_key_migration_notice_renders() { + let copy = single_key_migration_notice("savings"); + let copy_for_ui = copy.clone(); + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 220.0)) + .build_ui(move |ui| { + MessageBanner::set_global(ui.ctx(), ©_for_ui, MessageType::Warning) + .with_details(INTERIM_AT_REST_DETAILS); + MessageBanner::show_global(ui); + }); + harness.run(); + assert!( + harness.query_by_label(©).is_some(), + "Copy B must render verbatim", + ); + assert!(copy.contains("savings"), "Copy B names the key label"); +} + +/// QA-007 — Copy A and Copy B MUST be distinct text, or `MessageBanner`'s +/// `set_global` text-dedup would collapse them when a wallet and an imported +/// key migrate in the same session. +#[test] +fn qa_007_wallet_and_single_key_copies_are_distinct() { + let a = wallet_migration_notice("paycheque"); + let b = single_key_migration_notice("paycheque"); + assert_ne!(a, b, "Copy A and Copy B must differ so set_global keeps both"); + + // Both surface in one harness without collapsing. + let (a_ui, b_ui) = (a.clone(), b.clone()); + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 320.0)) + .build_ui(move |ui| { + MessageBanner::set_global(ui.ctx(), &a_ui, MessageType::Warning); + MessageBanner::set_global(ui.ctx(), &b_ui, MessageType::Warning); + MessageBanner::show_global(ui); + }); + harness.run(); + assert!(harness.query_by_label(&a).is_some(), "wallet notice present"); + assert!(harness.query_by_label(&b).is_some(), "key notice present"); +} + +/// QA-007 — the persona-facing copy stays jargon-free (no "AES", "vault", +/// "seam", "encryption", "0600"). The technical detail (Copy D) is opt-in and +/// likewise avoids raw internals. +#[test] +fn qa_007_disclosure_copy_is_jargon_free() { + let banned = ["AES", "vault", "seam", "encryption", "0600", "AES-GCM"]; + for copy in [ + wallet_migration_notice("w"), + single_key_migration_notice("k"), + INTERIM_AT_REST_DETAILS.to_string(), + ] { + for word in banned { + assert!( + !copy.contains(word), + "disclosure copy must avoid jargon {word:?}: {copy}", + ); + } + } +} diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index 690071059..ea1f66193 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -1,6 +1,7 @@ mod confirmation_dialog; mod create_asset_lock_screen; mod dashpay_screen; +mod disclosure_banner; mod identities_screen; mod import_single_key; mod info_popup; From 551d2084026b539fa5a8e5dfb3ea6c77a2e3a11b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:40:55 +0200 Subject: [PATCH 22/24] docs: comment hygiene + CLAUDE.md seam pointer + user-story softening (QA-DOC/DOC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA-DOC-01: strip ephemeral review IDs from comments I authored in the secret-seam surface — "Smythe must-fix #3/#4/#5", "Q-HEADLESS", "(F-2)", "6a2818cd" — keeping the rationale prose. (Pre-existing PROJ-010/TC-W-*/F43/F63 in code outside this PR's diff are left untouched to avoid scope creep.) QA-DOC-02: drop the "Promoted from…" history line in leak_test_support.rs (belongs in git, not the module header). QA-DOC-03: secret_access module-header resolution order now lists the unprotected fast-path as an explicit step 2 (cache → unprotected → prompt), matching the three-branch body. DOC-001: CLAUDE.md wallet_backend bullet now points at secret_seam.rs as the single secret chokepoint + the TODO(per-secret-encryption): grep convention + the design dir. DOC-002: user-stories WAL-006 gains the post-migration no-password-prompt note; WAL-025 "modern encrypted vault" → "on-device secret vault" (no longer asserts encryption that is presently absent — the accepted interim regression). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- CLAUDE.md | 2 +- docs/user-stories.md | 3 ++- src/wallet_backend/leak_test_support.rs | 12 ++++------ src/wallet_backend/secret_access.rs | 32 ++++++++++++------------- 4 files changed, 22 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e735c4cd5..56e03ebbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Code lives by responsibility, not convenience: - **`backend_task/`** — async business logic, one submodule per domain; the authoritative enforcement layer. `TaskError` and its typed variants live in `backend_task/error.rs`. - **`database/`** — SQLite persistence, one module per domain. - **`context/`** — `AppContext` submodules (`*_db.rs`, lifecycle, settings, status). -- **`wallet_backend/`** — the wallet orchestration seam: adapters, views, backend-side live caches, signers, the secret chokepoint, the event bridge. +- **`wallet_backend/`** — the wallet orchestration seam: adapters, views, backend-side live caches, signers, the secret chokepoint, the event bridge. All wallet secret bytes (HD seed, imported single key, identity private key) enter/leave the vault through ONE chokepoint, `wallet_backend/secret_seam.rs` (raw `SecretBytes`, no DET-side serialization). Per-secret encryption wires in there later — grep `TODO(per-secret-encryption):` for the exact sockets. Design + migration: `docs/ai-design/2026-06-19-secret-storage-seam/`. - **`ui//`** — screens (`ScreenLike`). UI may *call* `model/` validators for instant feedback but never implements its own validation. - **`ui/components/`** — reusable **Component-pattern widgets ONLY**: a `show()` plus a `ComponentResponse`, a display-only render widget, or component infrastructure. If it does not render egui, it is not a component. - **`ui/state/`** — non-widget UI state: per-screen view-models and async fetch-state caches (e.g. `TrackedAssetLockCache`). Owned by screens, may return `BackendTask`, render nothing. diff --git a/docs/user-stories.md b/docs/user-stories.md index 02cca9635..7fc26e975 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -74,6 +74,7 @@ As a user, I want my wallet protected by a passphrase so that others cannot acce - The prompt offers a "Keep this wallet unlocked until I close the app" option so a busy session is asked only once. - That option defaults to off: unless the user actively ticks it, every secret access re-prompts, and the seed is not cached. - The seed is never held in memory between operations: it is decrypted on demand and wiped as soon as the operation finishes. +- After the storage-seam migration, a previously password-protected wallet's secrets move to the on-device vault and the wallet no longer prompts for its password to open; a one-time notice at that unlock explains the change and that full password protection returns in a future update. ### WAL-007: Remove a wallet [Implemented] **Persona:** Priya, Jordan @@ -234,7 +235,7 @@ As a power user, I want the balance breakdown and address table to be collapsibl As a power user who imported a private key under an old per-key password, I want to restore that key after the storage update so that I do not lose access to the address. - A banner on the wallets screen counts the imported keys still waiting to be restored and offers to restore them. -- A per-key dialog takes the old password, decrypts the preserved key, and re-saves it in the modern encrypted vault (optionally under a new passphrase the user chooses). +- A per-key dialog takes the old password, decrypts the preserved key, and re-saves it in the on-device secret vault (optionally under a new passphrase the user chooses). - A wrong password fails with a calm, generic message and leaves the key restorable — the old data is never corrupted. - After restore the key appears in the wallet list at the same address; a note explains that balance and sending for single-key wallets arrive in a future update. diff --git a/src/wallet_backend/leak_test_support.rs b/src/wallet_backend/leak_test_support.rs index f0be81a30..2a6bd4f05 100644 --- a/src/wallet_backend/leak_test_support.rs +++ b/src/wallet_backend/leak_test_support.rs @@ -1,13 +1,9 @@ -//! Shared no-leak assertion for secret-path tests. -//! -//! Promoted from the private `assert_no_leak` in -//! `model/qualified_identity/encrypted_key_storage.rs::tests` so the seam, -//! sidecar, QI-blob, and `ClosedSingleKey`-Debug leak cases share one -//! implementation rather than copy-pasting it. +//! Shared no-leak assertion for secret-path tests — the seam, sidecar, QI-blob, +//! and `ClosedSingleKey`-Debug leak cases call one implementation. //! //! The decimal-array check is load-bearing: a `#[derive(Debug)]` on `[u8; N]` -//! leaks the `[160, 167, …]` decimal form, and finding `6a2818cd` leaked -//! exactly that. Hex alone would falsely pass against that bug. +//! leaks the `[160, 167, …]` decimal form. Hex alone would falsely pass against +//! a derived-Debug leak that emits that decimal shape. /// Assert `rendered` exposes `secret` in NONE of the forms a sink could leak /// it: lowercase hex and the `[160, 167, …]` decimal-array form. Works for any diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index 0453dfc88..6e5e2e279 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -10,16 +10,14 @@ //! //! Resolution order for each call: //! 1. session cache (only populated when the user opted in; TTL honored); -//! 2. else prompt via [`SecretPrompt`] for the passphrase, decrypt the -//! stored envelope just-in-time, optionally promote to the session -//! cache, run the closure, then zeroize. +//! 2. else, an **unprotected** scope (a migrated raw secret, or a no-password +//! HD wallet / no-passphrase imported key) resolves **without prompting** — +//! the chokepoint reads it directly with no passphrase; +//! 3. else prompt via [`SecretPrompt`] for the passphrase, decrypt the +//! stored secret just-in-time, optionally promote to the session cache, +//! run the closure, then zeroize. //! -//! Unprotected scopes (HD wallets stored without a password, imported keys -//! stored without a passphrase) resolve **without prompting** — the -//! envelope is decryptable with no passphrase, so the chokepoint reads it -//! directly (Smythe must-fix #4). -//! -//! Secret hygiene (Smythe must-fixes #1–#3): +//! Secret hygiene: //! - **Closure form, no storable guard.** [`SecretPlaintext`] and //! [`SecretSession`] are bound to the closure's lifetime; they cannot be //! parked across awaits outside the chokepoint. @@ -168,9 +166,9 @@ impl Plaintext { /// A session-cache entry: the boxed plaintext plus its expiry policy. /// -/// The plaintext is boxed (Smythe must-fix #3) so a `HashMap` rehash moves -/// only the `Box` pointer, never the secret bytes — no un-wiped inline copy -/// is left behind. `expires_at = None` means "until app close". +/// The plaintext is boxed so a `HashMap` rehash moves only the `Box` pointer, +/// never the secret bytes — no un-wiped inline copy is left behind. +/// `expires_at = None` means "until app close". struct SessionEntry { plaintext: Box, expires_at: Option<Instant>, @@ -543,7 +541,7 @@ impl SecretAccess { /// cancel from a non-interactive host /// ([`NullSecretPrompt`](crate::wallet_backend::secret_prompt::NullSecretPrompt)) /// means there was no window to ask in, surfaced as - /// [`TaskError::SecretPromptUnavailable`] (Q-HEADLESS). + /// [`TaskError::SecretPromptUnavailable`]. fn cancel_error(&self) -> TaskError { if self.inner.prompt.is_interactive() { TaskError::SecretPromptCancelled @@ -553,7 +551,7 @@ impl SecretAccess { } /// Whether `scope`'s stored secret is passphrase-protected. Drives the - /// unprotected fast-path (Smythe must-fix #4). + /// unprotected fast-path. /// /// Seam-first: a secret already migrated to its raw label has no /// passphrase (the user password no longer gates it). Only a not-yet- @@ -1040,7 +1038,7 @@ mod tests { async fn null_prompt_on_protected_scope_yields_unavailable() { // Headless host: a passphrase-protected scope has no window to ask // in, so the chokepoint surfaces the typed "unavailable" error - // rather than a misleading "you cancelled" (Q-HEADLESS). + // rather than a misleading "you cancelled". let dir = tempfile::tempdir().unwrap(); let store = fresh_store(dir.path()); let seed_hash: WalletSeedHash = [0x0C; 32]; @@ -1099,7 +1097,7 @@ mod tests { async fn can_resolve_without_prompt_tracks_protection_and_cache() { // The background identity sweep keys off this: an unprotected wallet or // a session-unlocked protected wallet resolves without a prompt; a - // locked protected wallet does not, so the sweep skips it (F-2). + // locked protected wallet does not, so the sweep skips it. let dir = tempfile::tempdir().unwrap(); let store = fresh_store(dir.path()); @@ -1359,7 +1357,7 @@ mod tests { assert_eq!(never.ask_count(), 0, "migrated key resolves prompt-free"); } - // --- secret confinement (Smythe must-fix #5) -------------------------- + // --- secret confinement ----------------------------------------------- #[tokio::test] async fn sentinel_never_appears_in_error_or_debug() { From 4dabef6aa75cd28d3b6c50ea4bbf08a957345aa0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:47:02 +0200 Subject: [PATCH 23/24] chore: nightly fmt for the QA-findings batch Whitespace-only reformat (cargo +nightly fmt --all) of the files touched while closing the QA findings. No behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- src/backend_task/wallet/sign_message_with_key.rs | 1 - src/context/identity_db.rs | 16 ++++++++++------ src/context/wallet_lifecycle.rs | 6 +++++- src/wallet_backend/secret_seam.rs | 3 ++- src/wallet_backend/single_key.rs | 10 +++++++--- src/wallet_backend/wallet_meta.rs | 5 +++-- tests/backend-e2e/identity_in_vault_sign.rs | 5 +---- tests/kittest/disclosure_banner.rs | 15 +++++++++------ 8 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/backend_task/wallet/sign_message_with_key.rs b/src/backend_task/wallet/sign_message_with_key.rs index 752f47809..8987b88f3 100644 --- a/src/backend_task/wallet/sign_message_with_key.rs +++ b/src/backend_task/wallet/sign_message_with_key.rs @@ -82,4 +82,3 @@ impl AppContext { }) } } - diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index d5ea2620d..be7cba044 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1459,8 +1459,8 @@ mod tests { use crate::model::qualified_identity::encrypted_key_storage::{ KeyStorage, PrivateKeyData, WalletDerivationPath, }; - use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget}; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget}; use crate::wallet_backend::IdentityKeyView; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -1470,9 +1470,7 @@ mod tests { fn fresh_vault(dir: &std::path::Path) -> Arc<platform_wallet_storage::secrets::SecretStore> { let path = dir.join("secrets.pwsvault"); - Arc::new( - crate::wallet_backend::single_key::open_secret_store(&path).expect("open vault"), - ) + Arc::new(crate::wallet_backend::single_key::open_secret_store(&path).expect("open vault")) } /// A `QualifiedIdentity` carrying one `Clear` (HIGH), one `AlwaysClear` @@ -1647,7 +1645,10 @@ mod tests { std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).ok(); assert_eq!(outcome, KeystoreMigration::VaultWriteFailed); - assert!(!persisted, "persist must NOT run when the vault write failed"); + assert!( + !persisted, + "persist must NOT run when the vault write failed" + ); assert_eq!( qi.private_keys, before, "the resident plaintext keystore must be restored on vault failure" @@ -1680,7 +1681,10 @@ mod tests { let pk = IdentityPublicKey::random_key(0, Some(0), pv); ks.private_keys.insert( (PrivateKeyTarget::PrivateKeyOnMainIdentity, 0), - (QualifiedIdentityPublicKey::from(pk), PrivateKeyData::InVault), + ( + QualifiedIdentityPublicKey::from(pk), + PrivateKeyData::InVault, + ), ); IdentityKeyView::new(&store, victim) .delete_all(ks.keys_set()) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index fa8a1cc65..884b6144c 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -27,7 +27,11 @@ pub const INTERIM_AT_REST_DETAILS: &str = "This wallet's secrets are now stored /// Distinct text from [`single_key_migration_notice`] so `MessageBanner`'s /// text-dedup never collapses the two when both migrate in one session. pub fn wallet_migration_notice(wallet: &str) -> String { - let wallet = if wallet.is_empty() { "Your wallet" } else { wallet }; + let wallet = if wallet.is_empty() { + "Your wallet" + } else { + wallet + }; format!( "\"{wallet}\" no longer needs its password to open. Your wallet stays on this device, protected by your computer's account. Full password protection will return in a future update." ) diff --git a/src/wallet_backend/secret_seam.rs b/src/wallet_backend/secret_seam.rs index 98c5265b4..6409b8e66 100644 --- a/src/wallet_backend/secret_seam.rs +++ b/src/wallet_backend/secret_seam.rs @@ -325,7 +325,8 @@ mod tests { // (`ciphertext`/`encrypted_seed`/`encrypted_private_key` are NOT here — // they hold AES-GCM ciphertext or migration-reader bytes, not plaintext; // the no-serialization invariant is about embedding live plaintext.) - const PLAINTEXT_NEEDLES: &[&str] = &["SecretBytes", "Zeroizing<[u8", ": [u8; 32]", ": [u8; 64]"]; + const PLAINTEXT_NEEDLES: &[&str] = + &["SecretBytes", "Zeroizing<[u8", ": [u8; 32]", ": [u8; 64]"]; let manifest = env!("CARGO_MANIFEST_DIR"); let mut offenders = Vec::new(); diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs index 0497a744e..281a778d0 100644 --- a/src/wallet_backend/single_key.rs +++ b/src/wallet_backend/single_key.rs @@ -1721,11 +1721,15 @@ mod tests { assert_eq!(got.alias.as_deref(), Some("legacy key")); assert!(got.has_passphrase); assert_eq!(got.passphrase_hint.as_deref(), Some("the usual")); - assert!(got.public_key_bytes.is_empty(), "no stored pubkey pre-migration"); + assert!( + got.public_key_bytes.is_empty(), + "no stored pubkey pre-migration" + ); // It was re-stored in the new shape: a direct new-shape decode succeeds. - let direct: Option<ImportedKey> = - kv.get(DetScope::Global, &key).expect("direct new-shape read"); + let direct: Option<ImportedKey> = kv + .get(DetScope::Global, &key) + .expect("direct new-shape read"); assert_eq!(direct.expect("present").address, address); } } diff --git a/src/wallet_backend/wallet_meta.rs b/src/wallet_backend/wallet_meta.rs index 6afdf9d11..777eff7f5 100644 --- a/src/wallet_backend/wallet_meta.rs +++ b/src/wallet_backend/wallet_meta.rs @@ -445,8 +445,9 @@ mod tests { // It was re-stored in the new shape: a direct new-shape decode now // succeeds (no more legacy fallback needed). - let direct: Option<WalletMeta> = - kv.get(DetScope::Global, &key).expect("direct new-shape read"); + let direct: Option<WalletMeta> = kv + .get(DetScope::Global, &key) + .expect("direct new-shape read"); assert_eq!(direct.expect("present").alias, alias); } } diff --git a/tests/backend-e2e/identity_in_vault_sign.rs b/tests/backend-e2e/identity_in_vault_sign.rs index 0cace5d59..93b4458e9 100644 --- a/tests/backend-e2e/identity_in_vault_sign.rs +++ b/tests/backend-e2e/identity_in_vault_sign.rs @@ -70,10 +70,7 @@ async fn ts_sign_e2e_01_in_vault_identity_signs_and_broadcasts() { qi.private_keys .private_keys .values() - .all(|(_, d)| !matches!( - d, - PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_) - )), + .all(|(_, d)| !matches!(d, PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_))), "no plaintext identity key may remain resident after migration" ); assert!( diff --git a/tests/kittest/disclosure_banner.rs b/tests/kittest/disclosure_banner.rs index 833e06005..dce258d93 100644 --- a/tests/kittest/disclosure_banner.rs +++ b/tests/kittest/disclosure_banner.rs @@ -30,10 +30,7 @@ fn qa_007_wallet_migration_notice_renders_as_warning() { harness.query_by_label(&copy).is_some(), "Copy A must render verbatim", ); - assert!( - copy.contains("paycheque"), - "Copy A names the wallet alias", - ); + assert!(copy.contains("paycheque"), "Copy A names the wallet alias",); // Warning glyph present. assert!( harness.query_by_label("\u{26A0}").is_some(), @@ -68,7 +65,10 @@ fn qa_007_single_key_migration_notice_renders() { fn qa_007_wallet_and_single_key_copies_are_distinct() { let a = wallet_migration_notice("paycheque"); let b = single_key_migration_notice("paycheque"); - assert_ne!(a, b, "Copy A and Copy B must differ so set_global keeps both"); + assert_ne!( + a, b, + "Copy A and Copy B must differ so set_global keeps both" + ); // Both surface in one harness without collapsing. let (a_ui, b_ui) = (a.clone(), b.clone()); @@ -80,7 +80,10 @@ fn qa_007_wallet_and_single_key_copies_are_distinct() { MessageBanner::show_global(ui); }); harness.run(); - assert!(harness.query_by_label(&a).is_some(), "wallet notice present"); + assert!( + harness.query_by_label(&a).is_some(), + "wallet notice present" + ); assert!(harness.query_by_label(&b).is_some(), "key notice present"); } From 1be4befc9a310b45984b17356c86953a37888a15 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:38:35 +0200 Subject: [PATCH 24/24] test(backend-e2e): seed Clear key so TS-SIGN-E2E-01 exercises the InVault JIT path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared_identity() fixture registers a wallet-derived identity, so its keys are PrivateKeyData::AtWalletDerivationPath and take_plaintext_for_vault() (which migrates only Clear/AlwaysClear) correctly found nothing — the test panicked in setup before reaching the path under test. Add materialize_master_key_as_clear(): derive the master key's raw bytes from the HD seed through the real with_secret(SecretScope::HdSeed) chokepoint (identity index 0, key 0) and insert_non_encrypted() them as Clear, so the migration carries a genuine plaintext key into the vault as InVault and the JIT signing path produces a signature whose bytes match the on-chain master key. The !taken.is_empty() assertion is unweakened; no signer stub, no mocked broadcast. Stays #[ignore]: the live broadcast additionally needs a funding wallet that derives within its rehydrated window (the e2e funding step hit the known core-wallet gap-window/rehydration limitation, unrelated to the InVault path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 --- tests/backend-e2e/identity_in_vault_sign.rs | 76 ++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/backend-e2e/identity_in_vault_sign.rs b/tests/backend-e2e/identity_in_vault_sign.rs index 93b4458e9..768ec8236 100644 --- a/tests/backend-e2e/identity_in_vault_sign.rs +++ b/tests/backend-e2e/identity_in_vault_sign.rs @@ -55,10 +55,20 @@ async fn ts_sign_e2e_01_in_vault_identity_signs_and_broadcasts() { let mut qi = si.qualified_identity.clone(); qi.identity = identity.clone(); + // The fixture registers from an HD wallet, so its keys are stored + // `AtWalletDerivationPath` (never plaintext-at-rest) and the migration would + // find nothing. Materialize the MASTER signing key to `Clear` first — + // mirroring the non-wallet load path (`load_identity.rs`) that yields + // `PrivateKeyData::Clear` — by deriving its raw bytes from the HD seed at + // the same identity-auth path production registered it at (index 0, key 0). + // Those bytes match the on-chain MASTER key, so the InVault signature + // verifies after migration. + materialize_master_key_as_clear(ctx, &si.wallet_seed_hash, &mut qi).await; + let taken = qi.private_keys.take_plaintext_for_vault(); assert!( !taken.is_empty(), - "the shared identity must have carried plaintext signing keys to migrate" + "the migrated MASTER key must have been materialized as plaintext to carry into the vault" ); IdentityKeyView::new(&ctx.app_context.secret_store(), identity_id.to_buffer()) .store_all(&taken) @@ -175,3 +185,67 @@ async fn ts_sign_e2e_01_in_vault_identity_signs_and_broadcasts() { "the new key must be visible on Platform — the InVault MASTER key signed the ST" ); } + +/// Rewrite the MASTER AUTHENTICATION key of `qi` from `AtWalletDerivationPath` +/// to a resident `PrivateKeyData::Clear`, deriving its raw bytes from the HD +/// seed at the same identity-auth path production registered it at. +/// +/// The fixture identity is wallet-derived, so the migration under test +/// (`take_plaintext_for_vault`) only acts on `Clear`/`AlwaysClear` keys. This +/// reproduces the load-path state in which a MASTER key is plaintext-at-rest, +/// so the migration → InVault → JIT-sign chain has a key to operate on. The +/// derived bytes are byte-identical to the on-chain MASTER key (same BIP-32 +/// path), so the signature it later produces verifies. +async fn materialize_master_key_as_clear( + ctx: &crate::framework::harness::BackendTestContext, + wallet_seed_hash: &dash_evo_tool::model::wallet::WalletSeedHash, + qi: &mut dash_evo_tool::model::qualified_identity::QualifiedIdentity, +) { + use dash_evo_tool::model::qualified_identity::PrivateKeyTarget; + use dash_evo_tool::wallet_backend::SecretScope; + use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; + + let network = ctx.app_context.network(); + + let (map_key, master_pub) = qi + .private_keys + .private_keys + .iter() + .find_map(|(map_key, (pub_key, _))| { + let ipk = &pub_key.identity_public_key; + (map_key.0 == PrivateKeyTarget::PrivateKeyOnMainIdentity + && ipk.purpose() == Purpose::AUTHENTICATION + && ipk.security_level() == SecurityLevel::MASTER) + .then(|| (map_key.clone(), pub_key.clone())) + }) + .expect("qualified identity must carry a MASTER AUTHENTICATION key"); + + // Production registers the MASTER key at identity index 0, key index 0. + let master_path = + DerivationPath::identity_authentication_path(network, KeyDerivationType::ECDSA, 0, 0); + + let master_bytes = ctx + .app_context + .wallet_backend() + .expect("wallet backend wired") + .secret_access() + .with_secret( + &SecretScope::HdSeed { + seed_hash: *wallet_seed_hash, + }, + |plaintext| { + let seed = plaintext + .expose_hd_seed() + .ok_or(dash_evo_tool::backend_task::error::TaskError::WalletLocked)?; + let xprv = master_path + .derive_priv_ecdsa_for_master_seed(seed, network) + .expect("derive master private key from seed"); + Ok(xprv.to_priv().inner.secret_bytes()) + }, + ) + .await + .expect("resolve HD seed and derive MASTER private key"); + + qi.private_keys + .insert_non_encrypted(map_key, (master_pub, master_bytes)); +}