Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -795,9 +795,6 @@ pub struct AppState {
/// MCP configuration held until a required boot-time network selection succeeds.
#[cfg(feature = "mcp")]
mcp_server_pending_config: Option<crate::mcp::McpConfig>,
/// The egui secret prompt host, kept so newly-created (on-demand) network
/// contexts can have it installed before their backend is wired.
secret_prompt_host: Arc<dyn crate::wallet_backend::SecretPrompt>,
/// Receives just-in-time passphrase requests enqueued by the egui secret
/// prompt host. Drained once per frame in [`Self::update`]; the active
/// request becomes [`Self::active_secret_prompt`].
Expand Down Expand Up @@ -1467,7 +1464,6 @@ impl AppState {
mcp_app_context,
#[cfg(feature = "mcp")]
mcp_server_pending_config,
secret_prompt_host,
secret_prompt_receiver,
active_secret_prompt: None,
prompt_was_blocking: false,
Expand Down Expand Up @@ -1956,7 +1952,9 @@ impl AppState {
return;
}
self.select_main_screen(root_screen_type);
self.active_root_screen_mut().refresh_on_arrival();
let active_screen = self.active_root_screen_mut();
active_screen.reset_to_root_view();
active_screen.refresh_on_arrival();
Comment thread
lklimek marked this conversation as resolved.
self.current_app_context()
.update_settings(root_screen_type)
.ok();
Expand Down Expand Up @@ -2225,11 +2223,6 @@ impl App for AppState {
context,
..
} => {
// Install the egui prompt host before the new
// context's backend is wired, so its `SecretAccess`
// gets the interactive host rather than the headless
// default.
context.install_secret_prompt(Arc::clone(&self.secret_prompt_host));
self.network_contexts.insert(network, context);
self.network_switch_pending = None;
self.network_switch_banner.take_and_clear();
Expand Down
65 changes: 65 additions & 0 deletions src/backend_task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,7 @@ impl AppContext {
)
})
.ok_or(TaskError::NetworkContextCreationFailed { network })?;
new_ctx.install_secret_prompt(self.secret_prompt());

// Wire the freshly-built context's wallet backend and then start
// chain sync. The old code called `start_spv()` on an unwired
Expand Down Expand Up @@ -1277,6 +1278,70 @@ mod tests {
backend.shutdown().await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn switch_network_propagates_secret_prompt_before_wallet_backend_wiring() {
use crate::context::test_support::test_app_context;
use crate::model::qualified_identity::PrivateKeyTarget;
use crate::wallet_backend::secret_prompt::SecretScope;
use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt};
use crate::wallet_backend::{IdentityKeyView, SecretPrompt};
use platform_wallet_storage::secrets::SecretString;

let tmp = tempfile::tempdir().expect("tempdir");
let ctx = test_app_context(tmp.path());
let identity_id = [0x91; 32];
let target = PrivateKeyTarget::PrivateKeyOnMainIdentity;
let key_id = 7;
let key = [0xa5; 32];
let password = "network-switch-password";
let secret_store = ctx.secret_store();
IdentityKeyView::new(&secret_store, identity_id)
.store_protected(&target, key_id, &key, &SecretString::new(password))
.expect("store protected identity key");
let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(password)]));
ctx.install_secret_prompt(Arc::clone(&prompt) as Arc<dyn SecretPrompt>);

let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32);
let sender = SenderAsync::new(tx, ctx.egui_ctx().clone());
let result = ctx
.run_backend_task(
BackendTask::SwitchNetwork {
network: Network::Mainnet,
start_spv: true,
},
sender,
)
.await
.expect("switch network");

let BackendTaskSuccessResult::NetworkContextCreated { context, .. } = result else {
panic!("expected a newly created network context");
};
let backend = context.wallet_backend().expect("switched backend wired");
let scope = SecretScope::IdentityKey {
identity_id,
target,
key_id,
};
let resolved = backend
.secret_access()
.with_secret(&scope, |plaintext| {
Ok(plaintext.expose_identity_key().copied() == Some(key))
})
.await
.expect("resolve protected key through switched backend");
assert!(
resolved,
"the wired backend must resolve through the source interactive prompt"
);
assert_eq!(
prompt.ask_count(),
1,
"the switched backend must prompt once"
);
backend.shutdown().await;
}
Comment thread
claude[bot] marked this conversation as resolved.

#[test]
fn dapi_success_skips_availability_inspection() {
let inspected = std::cell::Cell::new(false);
Expand Down
31 changes: 28 additions & 3 deletions src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1146,9 +1146,13 @@ impl AppContext {
/// [`NullSecretPrompt`], which surfaces `SecretPromptUnavailable` for any
/// passphrase-protected scope.
pub fn install_secret_prompt(&self, prompt: Arc<dyn SecretPrompt>) {
if let Ok(mut guard) = self.secret_prompt.0.lock() {
*guard = prompt;
}
let mut guard = self
.secret_prompt
.0
.lock()
.unwrap_or_else(|poison| poison.into_inner());
*guard = prompt;
self.secret_prompt.0.clear_poison();
}

/// The currently-installed secret-prompt host. Falls back to the headless
Expand Down Expand Up @@ -1510,6 +1514,27 @@ pub(crate) const fn default_platform_version(_network: &Network) -> &'static Pla
mod tests {
use super::*;

#[test]
fn install_secret_prompt_recovers_poisoned_slot() {
use crate::context::test_support::test_app_context;
use crate::wallet_backend::secret_prompt::test_support::TestPrompt;

let tmp = tempfile::tempdir().expect("tempdir");
let ctx = test_app_context(tmp.path());
let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = ctx.secret_prompt.0.lock().expect("prompt slot lock");
panic!("poison prompt slot");
}));
assert!(poisoned.is_err(), "the prompt slot must be poisoned");

let prompt: Arc<dyn SecretPrompt> = Arc::new(TestPrompt::never());
ctx.install_secret_prompt(Arc::clone(&prompt));
assert!(
Arc::ptr_eq(&prompt, &ctx.secret_prompt()),
"install_secret_prompt must replace the host after lock poisoning"
);
}

#[test]
fn wallet_name_with_spaces_is_url_encoded() {
let base = "http://127.0.0.1:9998";
Expand Down
66 changes: 66 additions & 0 deletions src/mcp/tools/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,69 @@ impl AsyncTool<DashMcpService> for NetworkSwitch {
}
}
}

#[cfg(all(test, feature = "mcp"))]
mod tests {
use std::sync::Arc;

use super::*;
use crate::context::test_support::test_app_context;
use crate::model::qualified_identity::PrivateKeyTarget;
use crate::wallet_backend::secret_prompt::SecretScope;
use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt};
use crate::wallet_backend::{IdentityKeyView, SecretPrompt};
use platform_wallet_storage::secrets::SecretString;

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn network_switch_tool_preserves_secret_prompt_identity() {
let tmp = tempfile::tempdir().expect("tempdir");
let ctx = test_app_context(tmp.path());
let identity_id = [0x92; 32];
let target = PrivateKeyTarget::PrivateKeyOnMainIdentity;
let key_id = 8;
let key = [0xb6; 32];
let password = "mcp-network-switch-password";
let secret_store = ctx.secret_store();
IdentityKeyView::new(&secret_store, identity_id)
.store_protected(&target, key_id, &key, &SecretString::new(password))
.expect("store protected identity key");
let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(password)]));
ctx.install_secret_prompt(Arc::clone(&prompt) as Arc<dyn SecretPrompt>);
let shared = Arc::new(arc_swap::ArcSwap::from(ctx));
let service = DashMcpService::new_shared(shared);

NetworkSwitch::invoke(
&service,
NetworkSwitchParams {
network: "mainnet".to_owned(),
},
)
.await
.expect("switch network through MCP");

let switched = service.tool_ctx().await.expect("switched context");
let backend = switched.wallet_backend().expect("switched backend wired");
let scope = SecretScope::IdentityKey {
identity_id,
target,
key_id,
};
let resolved = backend
.secret_access()
.with_secret(&scope, |plaintext| {
Ok(plaintext.expose_identity_key().copied() == Some(key))
})
.await
.expect("resolve protected key through MCP-switched backend");
assert!(
resolved,
"the MCP-swapped backend must resolve through the source interactive prompt"
);
assert_eq!(
prompt.ask_count(),
1,
"the MCP-switched backend must prompt once"
);
backend.shutdown().await;
}
}
27 changes: 27 additions & 0 deletions src/ui/identities/keys/key_info_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,31 @@ impl KeyInfoScreen {
}
}

/// Build a key-info screen with the add-protection confirmation already open
/// when vault-backed protection is available, or show a warning when wallet
/// setup has not made protection available yet.
pub fn new_with_protection_prompt(
identity: QualifiedIdentity,
key: IdentityPublicKey,
private_key_data: Option<(PrivateKeyData, Option<WalletDerivationPath>)>,
app_context: &Arc<AppContext>,
) -> Self {
let mut screen = Self::new(identity, key, private_key_data, app_context);
let status = screen.compute_protection_status();
if status == IdentityProtectionStatus::NoVaultKeys {
screen.protection_stage = ProtectionStage::Idle;
MessageBanner::set_global(
app_context.egui_ctx(),
"Password protection is not available yet. Wait for wallet setup to finish, then try again.",
MessageType::Warning,
);
} else {
screen.protection_status = Some(status);
screen.open_add_confirm();
}
screen
Comment on lines +802 to +815

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Revalidation still opens the add flow for already-protected keys

new_with_protection_prompt's status recheck only special-cases IdentityProtectionStatus::NoVaultKeys; every other variant, including Protected, calls open_add_confirm(). If another GUI or MCP operation completes protection between the detail view's stale status probe and this constructor, the resulting screen reports protection as active while presenting an Add-protection confirmation. Submitting the existing password against that stale flow returns IdentityKeysProtected { count: 0 } and can rewrite the hint; a different password appears to fail as though a real migration were attempted. Match the enum exhaustively and only open the add flow for Unprotected or Mixed.

Suggested change
let mut screen = Self::new(identity, key, private_key_data, app_context);
let status = screen.compute_protection_status();
if status == IdentityProtectionStatus::NoVaultKeys {
screen.protection_stage = ProtectionStage::Idle;
MessageBanner::set_global(
app_context.egui_ctx(),
"Password protection is not available yet. Wait for wallet setup to finish, then try again.",
MessageType::Warning,
);
} else {
screen.protection_status = Some(status);
screen.open_add_confirm();
}
screen
let mut screen = Self::new(identity, key, private_key_data, app_context);
let status = screen.compute_protection_status();
screen.protection_status = Some(status);
match status {
IdentityProtectionStatus::NoVaultKeys => {
screen.protection_stage = ProtectionStage::Idle;
MessageBanner::set_global(
app_context.egui_ctx(),
"Password protection is not available yet. Wait for wallet setup to finish, then try again.",
MessageType::Warning,
);
}
IdentityProtectionStatus::Unprotected | IdentityProtectionStatus::Mixed => {
screen.open_add_confirm();
}
IdentityProtectionStatus::Protected => {}
}
screen

source: ['codex']

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still valid at f223237. new_with_protection_prompt continues to special-case only NoVaultKeys; the else branch fires open_add_confirm() for Protected and Mixed alike. A key protected out-of-band between the detail view's probe and this constructor still lands the user in an Add-protection flow against already-sealed keys. Leaving this thread open — the exhaustive-match fix you proposed remains the right call.

🤖 Claudius the Magnificent

}

fn validate_and_store_private_key(&mut self) {
// Convert the input string to bytes (hex decoding)
let private_key_bytes = match hex::decode(self.private_key_input.text()) {
Expand Down Expand Up @@ -1099,6 +1124,8 @@ impl KeyInfoScreen {

egui::CollapsingHeader::new("Key Protection")
.default_open(false)
// Keep the header open so an active protection form or dialog remains visible.
.open((self.protection_stage != ProtectionStage::Idle).then_some(true))
.show(ui, |ui| {
let status_text = match status {
IdentityProtectionStatus::Unprotected => {
Expand Down
42 changes: 35 additions & 7 deletions src/ui/masternodes/detail_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ pub enum DetailOutcome {
Forward(Box<AppAction>),
}

enum KeyInfoOpenMode {
Normal,
WithProtectionPrompt,
}

/// Masternode/evonode detail view state.
pub struct MasternodeDetailView {
app_context: Arc<AppContext>,
Expand Down Expand Up @@ -659,7 +664,7 @@ impl MasternodeDetailView {
&& let Some((target, key)) = self.first_protectable_key()
&& ui.button("Add password protection…").clicked()
{
action = Some(self.open_key_info(target, &key));
action = Some(self.open_key_info_with_protection_prompt(target, &key));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM — this new routing makes the headline feature a silent no-op for multi-key masternodes

This line newly routes the "Add password protection…" CTA into open_key_info_with_protection_prompt, which selects its target via first_protectable_key (detail_screen.rs:686). Two independent reviewers flagged the same trap, and I'm afraid they're right.

first_protectable_key picks the first key whose held data is merely .is_some() — matching any PrivateKeyData variant (Clear, AlwaysClear, Encrypted, AtWalletDerivationPath, InVault). But KeyInfoScreen only draws the protection confirmation dialog inside the PrivateKeyData::InVault match arm (key_info_screen.rs:559-579). So when the selection lands on a non-InVault key, new_with_protection_prompt dutifully arms ConfirmAdd, the screen opens… and nothing renders. No dialog, no error, no next step — precisely the dead-end this PR set out to abolish.

It's reachable, not hypothetical: identity_keys() iterates in KeyID order, while the CTA visibility gate counts vault keys via view.scheme — divergent criteria. migrate_keystore_to_vault migrates only plaintext (Clear/AlwaysClear); AtWalletDerivationPath and legacy Encrypted keys are left untouched. An evonode/masternode identity holding a wallet-derived auth key (lower KeyID) alongside an InVault owner/voting key routes the CTA straight to the key the seal screen can't render.

The existing add_password_protection_opens_confirmation_dialog kittest seeds a single Clear key that migrates to InVault, so the selected key and the only vault key are trivially identical — it cannot catch this.

Fix: filter first_protectable_key to keys whose held PrivateKeyData is InVault (ideally also IdentityKeyView::scheme == Unprotected, mirroring the tier gate). Or make new_with_protection_prompt fail closed with a MessageBanner warning — as it already does for NoVaultKeys — when the routed key isn't InVault. Its doc comment ("the only keys that can be sealed") is also false for the non-vault variants. Please add a kittest seeding a wallet-derived/encrypted key at a lower KeyID than the Unprotected vault key so this stays buried.

🤖 Claudius the Magnificent — grumpy review, MEDIUM (CODE-001)

}
action
}
Expand Down Expand Up @@ -701,17 +706,40 @@ impl MasternodeDetailView {
&self,
target: PrivateKeyTarget,
key: &dash_sdk::platform::IdentityPublicKey,
) -> AppAction {
self.open_key_info_with_mode(target, key, KeyInfoOpenMode::Normal)
}

/// Open `KeyInfoScreen` directly in the add-protection confirmation flow.
fn open_key_info_with_protection_prompt(
&self,
target: PrivateKeyTarget,
key: &dash_sdk::platform::IdentityPublicKey,
) -> AppAction {
self.open_key_info_with_mode(target, key, KeyInfoOpenMode::WithProtectionPrompt)
}

fn open_key_info_with_mode(
&self,
target: PrivateKeyTarget,
key: &dash_sdk::platform::IdentityPublicKey,
mode: KeyInfoOpenMode,
) -> AppAction {
let holding = self
.identity
.private_keys
.get_cloned_private_key_data_and_wallet_info(&(target, key.id()));
AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new(
self.identity.clone(),
key.clone(),
holding,
&self.app_context,
)))
let identity = self.identity.clone();
let key = key.clone();
let screen = match mode {
KeyInfoOpenMode::Normal => {
KeyInfoScreen::new(identity, key, holding, &self.app_context)
}
KeyInfoOpenMode::WithProtectionPrompt => {
KeyInfoScreen::new_with_protection_prompt(identity, key, holding, &self.app_context)
}
};
AppAction::AddScreen(Screen::KeyInfoScreen(screen))
}

/// Render the collapsible DPNS voting section (collapsed by default,
Expand Down
12 changes: 12 additions & 0 deletions src/ui/masternodes/list_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,12 @@ impl ScreenLike for MasternodesScreen {
self.reconcile_pending_load();
}

fn reset_to_root_view(&mut self) {
if !matches!(self.view, MasternodesView::Load(_)) {
self.view = MasternodesView::List;
}
Comment thread
claude[bot] marked this conversation as resolved.
}

/// Drop every secret the open view holds — the load form's keys and
/// encryption password, the detail view's unsubmitted voting key. This screen
/// is a root screen: it lives for the whole process, so nothing else would
Expand Down Expand Up @@ -874,6 +880,12 @@ mod tests {
"submit must re-enable after a failed load"
);

screen.reset_to_root_view();
assert!(
matches!(screen.view, MasternodesView::Load(_)),
"root navigation must preserve a failed load form for correction"
);

// Resubmit, then succeed: the form closes and drops back to the list.
submit_load(&mut screen, target);
claim_dispatched_load(&ctx, &screen).loaded();
Expand Down
10 changes: 10 additions & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,12 @@ pub trait ScreenLike {
self.refresh()
}

/// Reset a persistent root screen to the view selected by its navigation entry.
/// Called only by `AppState::set_main_screen` on root navigation, not by
/// [`refresh_on_arrival`](ScreenLike::refresh_on_arrival) or [`on_leave`](ScreenLike::on_leave),
/// so returning from a pushed sub-screen preserves the current sub-view.
fn reset_to_root_view(&mut self) {}

/// Called by `AppState` when this root screen stops being the selected one.
///
/// The counterpart of [`refresh_on_arrival`](ScreenLike::refresh_on_arrival).
Expand Down Expand Up @@ -1087,6 +1093,10 @@ impl ScreenLike for Screen {
delegate_to_screen!(self, screen => screen.refresh_on_arrival())
}

fn reset_to_root_view(&mut self) {
delegate_to_screen!(self, screen => screen.reset_to_root_view())
}

fn on_leave(&mut self) {
delegate_to_screen!(self, screen => screen.on_leave())
}
Expand Down
Loading
Loading