From 0d0b3a7c75953e93cbf6e9c6fc0486c4bcbe46e5 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 09:37:23 +0100 Subject: [PATCH] fix: wipe long-lived credential copies from memory Session-only credentials (kept for a whole session so reconnects don't re-prompt), credentials pending until a connection succeeds, and the credential prompt's text buffer are now Zeroizing and wiped when dropped, including when a prompt is cancelled or abandoned (SEC-20261012-007, partial). Still open: the values handed to the protocol clients are plain Strings for the duration of authentication, egui keeps its own copies of typed text, and no mlock/PR_SET_DUMPABLE is applied. --- rust/Cargo.lock | 1 + rust/app/Cargo.toml | 1 + rust/app/src/app.rs | 19 ++++++++++++++++--- rust/app/src/chrome.rs | 4 ++-- rust/core/src/sessions.rs | 17 +++++++++-------- 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5ea4c5d..d87c2a7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2812,6 +2812,7 @@ dependencies = [ "serde", "serde_json", "winresource", + "zeroize", ] [[package]] diff --git a/rust/app/Cargo.toml b/rust/app/Cargo.toml index 1c7bd7e..c2c2fed 100644 --- a/rust/app/Cargo.toml +++ b/rust/app/Cargo.toml @@ -13,6 +13,7 @@ nexus-core = { path = "../core" } eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] } egui = "0.36" parking_lot = "0.12" +zeroize = "1" egui-phosphor = "0.14" rfd = "0.17" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index 4e37505..da04fe6 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -50,8 +50,21 @@ pub fn post_error_nonblocking(e: String) { /// A credential or host-key question from a connecting session. pub enum Prompt { - Secret { session: String, title: String, message: String, value: String, reply: mpsc::Sender }, - HostKey { host: String, key_type: String, fingerprint: String, stored: Option, reply: mpsc::Sender }, + /// `value` is wiped when the prompt is dropped (answered, cancelled or abandoned). + Secret { + session: String, + title: String, + message: String, + value: zeroize::Zeroizing, + reply: mpsc::Sender, + }, + HostKey { + host: String, + key_type: String, + fingerprint: String, + stored: Option, + reply: mpsc::Sender, + }, } /// Modal dialogs owned by the main window. @@ -507,7 +520,7 @@ impl App { let _ = (session, title, message, reply.send(self.answers.remove(0))); } Event::SecretPrompt { session, title, message, reply } => { - self.prompts.push_back(Prompt::Secret { session, title, message, value: String::new(), reply }); + self.prompts.push_back(Prompt::Secret { session, title, message, value: Default::default(), reply }); } Event::HostKeyVerify { host, key_type, fingerprint, stored, reply, .. } => { self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, stored, reply }); diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs index ec93fcb..026ce04 100644 --- a/rust/app/src/chrome.rs +++ b/rust/app/src/chrome.rs @@ -695,12 +695,12 @@ pub fn prompts(app: &mut App, ctx: &egui::Context) { } ui.add_space(4.0); ui.label(message.as_str()); - let r = ui.add(egui::TextEdit::singleline(value).password(true).desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(&mut **value).password(true).desired_width(f32::INFINITY)); r.request_focus(); ui.add_space(6.0); ui.horizontal(|ui| { if widgets::primary(ui, icon::CHECK, "OK").clicked() || enter { - let _ = reply.send(std::mem::take(value)); + let _ = reply.send(std::mem::take(&mut **value)); done = true; } if ui.button("Cancel").clicked() || esc { diff --git a/rust/core/src/sessions.rs b/rust/core/src/sessions.rs index 78dbdbf..36df7e8 100644 --- a/rust/core/src/sessions.rs +++ b/rust/core/src/sessions.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::time::Duration; +use zeroize::Zeroizing; /// Everything the UI hears about. Prompts carry their reply channel; dropping it cancels. pub enum Event { @@ -209,11 +210,11 @@ type SessionSecrets = Arc>; #[derive(Default)] struct SecretState { - /// `SecretMode::SessionOnly` values, asked once per session. - session_only: HashMap, + /// `SecretMode::SessionOnly` values, asked once per session. Wiped from memory on drop. + session_only: HashMap>, /// `SecretMode::Saved` values typed during this attempt; stored only once it connects, so a /// typo never ends up saved. - pending: HashMap, + pending: HashMap>, /// Saved values this attempt used. used: HashSet, /// Saved values the server rejected: the next attempt asks again instead of reusing them. @@ -491,7 +492,7 @@ impl Manager { if mode == SecretMode::Saved { let mut st = state.lock(); if let Some(v) = st.pending.get(&key) { - return Ok(v.clone()); + return Ok(v.to_string()); } if !st.rejected.remove(&key) && let Some(v) = secrets.get(&key) @@ -502,7 +503,7 @@ impl Manager { } } if mode == SecretMode::SessionOnly - && let Some(v) = state.lock().session_only.get(&key).cloned() + && let Some(v) = state.lock().session_only.get(&key).map(|v| v.to_string()) { return Ok(v); } @@ -510,10 +511,10 @@ impl Manager { let mut st = state.lock(); match mode { SecretMode::Saved => { - st.pending.insert(key.clone(), v.clone()); + st.pending.insert(key.clone(), Zeroizing::new(v.clone())); } SecretMode::SessionOnly => { - st.session_only.insert(key.clone(), v.clone()); + st.session_only.insert(key.clone(), Zeroizing::new(v.clone())); } SecretMode::AskEveryTime => {} } @@ -696,7 +697,7 @@ impl Manager { let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty()); let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) }; if gh.secret_mode == SecretMode::Saved { - let _ = self.with(id, |s| s.session_secrets.lock().pending.insert(SecretKind::GithubToken.key(&p.id), t.clone())); + let _ = self.with(id, |s| s.session_secrets.lock().pending.insert(SecretKind::GithubToken.key(&p.id), Zeroizing::new(t.clone()))); } client = github::Client::new(&gh.base_url, &t); client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))?