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.
This commit is contained in:
Generated
+1
@@ -2812,6 +2812,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"winresource",
|
"winresource",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ nexus-core = { path = "../core" }
|
|||||||
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] }
|
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] }
|
||||||
egui = "0.36"
|
egui = "0.36"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
zeroize = "1"
|
||||||
egui-phosphor = "0.14"
|
egui-phosphor = "0.14"
|
||||||
rfd = "0.17"
|
rfd = "0.17"
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|||||||
+16
-3
@@ -50,8 +50,21 @@ pub fn post_error_nonblocking(e: String) {
|
|||||||
|
|
||||||
/// A credential or host-key question from a connecting session.
|
/// A credential or host-key question from a connecting session.
|
||||||
pub enum Prompt {
|
pub enum Prompt {
|
||||||
Secret { session: String, title: String, message: String, value: String, reply: mpsc::Sender<String> },
|
/// `value` is wiped when the prompt is dropped (answered, cancelled or abandoned).
|
||||||
HostKey { host: String, key_type: String, fingerprint: String, stored: Option<String>, reply: mpsc::Sender<HostKeyDecision> },
|
Secret {
|
||||||
|
session: String,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
value: zeroize::Zeroizing<String>,
|
||||||
|
reply: mpsc::Sender<String>,
|
||||||
|
},
|
||||||
|
HostKey {
|
||||||
|
host: String,
|
||||||
|
key_type: String,
|
||||||
|
fingerprint: String,
|
||||||
|
stored: Option<String>,
|
||||||
|
reply: mpsc::Sender<HostKeyDecision>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modal dialogs owned by the main window.
|
/// Modal dialogs owned by the main window.
|
||||||
@@ -507,7 +520,7 @@ impl App {
|
|||||||
let _ = (session, title, message, reply.send(self.answers.remove(0)));
|
let _ = (session, title, message, reply.send(self.answers.remove(0)));
|
||||||
}
|
}
|
||||||
Event::SecretPrompt { session, title, message, reply } => {
|
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, .. } => {
|
Event::HostKeyVerify { host, key_type, fingerprint, stored, reply, .. } => {
|
||||||
self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, stored, reply });
|
self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, stored, reply });
|
||||||
|
|||||||
@@ -695,12 +695,12 @@ pub fn prompts(app: &mut App, ctx: &egui::Context) {
|
|||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.label(message.as_str());
|
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();
|
r.request_focus();
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if widgets::primary(ui, icon::CHECK, "OK").clicked() || enter {
|
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;
|
done = true;
|
||||||
}
|
}
|
||||||
if ui.button("Cancel").clicked() || esc {
|
if ui.button("Cancel").clicked() || esc {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use std::sync::Arc;
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
/// Everything the UI hears about. Prompts carry their reply channel; dropping it cancels.
|
/// Everything the UI hears about. Prompts carry their reply channel; dropping it cancels.
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
@@ -209,11 +210,11 @@ type SessionSecrets = Arc<Mutex<SecretState>>;
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct SecretState {
|
struct SecretState {
|
||||||
/// `SecretMode::SessionOnly` values, asked once per session.
|
/// `SecretMode::SessionOnly` values, asked once per session. Wiped from memory on drop.
|
||||||
session_only: HashMap<String, String>,
|
session_only: HashMap<String, Zeroizing<String>>,
|
||||||
/// `SecretMode::Saved` values typed during this attempt; stored only once it connects, so a
|
/// `SecretMode::Saved` values typed during this attempt; stored only once it connects, so a
|
||||||
/// typo never ends up saved.
|
/// typo never ends up saved.
|
||||||
pending: HashMap<String, String>,
|
pending: HashMap<String, Zeroizing<String>>,
|
||||||
/// Saved values this attempt used.
|
/// Saved values this attempt used.
|
||||||
used: HashSet<String>,
|
used: HashSet<String>,
|
||||||
/// Saved values the server rejected: the next attempt asks again instead of reusing them.
|
/// Saved values the server rejected: the next attempt asks again instead of reusing them.
|
||||||
@@ -491,7 +492,7 @@ impl Manager {
|
|||||||
if mode == SecretMode::Saved {
|
if mode == SecretMode::Saved {
|
||||||
let mut st = state.lock();
|
let mut st = state.lock();
|
||||||
if let Some(v) = st.pending.get(&key) {
|
if let Some(v) = st.pending.get(&key) {
|
||||||
return Ok(v.clone());
|
return Ok(v.to_string());
|
||||||
}
|
}
|
||||||
if !st.rejected.remove(&key)
|
if !st.rejected.remove(&key)
|
||||||
&& let Some(v) = secrets.get(&key)
|
&& let Some(v) = secrets.get(&key)
|
||||||
@@ -502,7 +503,7 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if mode == SecretMode::SessionOnly
|
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);
|
return Ok(v);
|
||||||
}
|
}
|
||||||
@@ -510,10 +511,10 @@ impl Manager {
|
|||||||
let mut st = state.lock();
|
let mut st = state.lock();
|
||||||
match mode {
|
match mode {
|
||||||
SecretMode::Saved => {
|
SecretMode::Saved => {
|
||||||
st.pending.insert(key.clone(), v.clone());
|
st.pending.insert(key.clone(), Zeroizing::new(v.clone()));
|
||||||
}
|
}
|
||||||
SecretMode::SessionOnly => {
|
SecretMode::SessionOnly => {
|
||||||
st.session_only.insert(key.clone(), v.clone());
|
st.session_only.insert(key.clone(), Zeroizing::new(v.clone()));
|
||||||
}
|
}
|
||||||
SecretMode::AskEveryTime => {}
|
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 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}")) };
|
let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) };
|
||||||
if gh.secret_mode == SecretMode::Saved {
|
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 = github::Client::new(&gh.base_url, &t);
|
||||||
client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))?
|
client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))?
|
||||||
|
|||||||
Reference in New Issue
Block a user