fix: minimize and zeroize owned password buffers

This commit is contained in:
Valdemar Lorenzen
2026-10-20 12:56:38 +02:00
parent 499e070397
commit 128c6ee829
4 changed files with 79 additions and 18 deletions
Generated
+7
View File
@@ -278,6 +278,7 @@ dependencies = [
"shellexpand", "shellexpand",
"sqlparser", "sqlparser",
"tempfile", "tempfile",
"zeroize",
] ]
[[package]] [[package]]
@@ -1719,3 +1720,9 @@ name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+1
View File
@@ -13,6 +13,7 @@ native-tls = "0.2"
rusqlite = { version = "0.40", default-features = false, features = ["bundled"] } rusqlite = { version = "0.40", default-features = false, features = ["bundled"] }
shellexpand = { version = "3.1", features = ["path"] } shellexpand = { version = "3.1", features = ["path"] }
sqlparser = { version = "0.63", default-features = false, features = ["std"] } sqlparser = { version = "0.63", default-features = false, features = ["std"] }
zeroize = "1"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+64 -13
View File
@@ -9,6 +9,7 @@ use ratatui::{
text::{Line, Span, Text}, text::{Line, Span, Text},
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}, widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
}; };
use zeroize::{Zeroize, Zeroizing};
use crate::{ use crate::{
Result, Result,
@@ -373,18 +374,21 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
} }
} }
} }
Event::Paste(_) if info.is_some() || help.is_some() || remove.is_some() => {} Event::Paste(mut text) if info.is_some() || help.is_some() || remove.is_some() => text.zeroize(),
Event::Paste(text) if show_form => { Event::Paste(text) if show_form => {
let text = Zeroizing::new(text);
form.error = None; form.error = None;
if let Some(input) = form.input_mut() { if let Some(input) = form.input_mut() {
input.paste(&text); input.paste(&text);
} }
} }
Event::Paste(text) if focus == Focus::Query => { Event::Paste(text) if focus == Focus::Query => {
let text = Zeroizing::new(text);
if let Some(browser) = &mut browser { if let Some(browser) = &mut browser {
browser.paste_query(&text); browser.paste_query(&text);
} }
} }
Event::Paste(mut text) => text.zeroize(),
_ => {} _ => {}
} }
} }
@@ -643,13 +647,25 @@ impl Profiles {
} }
fn save(&mut self, form: &Form) -> Result<()> { fn save(&mut self, form: &Form) -> Result<()> {
let mut saved = form.clone(); let mut saved = Form {
saved.clear_password(); id: form.id,
saved.reconnect = false; name: Input::from(form.name.value.trim()),
saved.error = None; provider: form.provider,
saved.name = Input::from(saved.name.value.trim()); focus: form.focus,
postgres: std::array::from_fn(|index| {
if index == 4 {
Input::default()
} else {
form.postgres[index].clone()
}
}),
tls: form.tls,
path: form.path.clone(),
error: None,
reconnect: false,
};
if form.provider == 0 { if form.provider == 0 {
form.postgres_config()?; saved.postgres_config()?;
} else if form.path.value.is_empty() { } else if form.path.value.is_empty() {
return Err("Enter a SQLite database file path.".into()); return Err("Enter a SQLite database file path.".into());
} else { } else {
@@ -803,7 +819,8 @@ impl Form {
} }
fn clear_password(&mut self) { fn clear_password(&mut self) {
self.postgres[4] = Input::default(); self.postgres[4].value.zeroize();
self.postgres[4].cursor = 0;
} }
fn edit(&mut self, key: KeyEvent) { fn edit(&mut self, key: KeyEvent) {
@@ -1076,6 +1093,12 @@ pub(crate) struct Input {
cursor: usize, cursor: usize,
} }
impl Drop for Input {
fn drop(&mut self) {
self.value.zeroize();
}
}
impl From<&str> for Input { impl From<&str> for Input {
fn from(value: &str) -> Self { fn from(value: &str) -> Self {
Self { Self {
@@ -1240,7 +1263,7 @@ impl Input {
self.value.remove(self.cursor); self.value.remove(self.cursor);
} }
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.value.clear(); self.value.zeroize();
self.cursor = 0; self.cursor = 0;
} }
KeyCode::Char(character) KeyCode::Char(character)
@@ -1257,20 +1280,24 @@ impl Input {
} }
fn paste(&mut self, value: &str) { fn paste(&mut self, value: &str) {
let text: String = value let text = Zeroizing::new(
value
.chars() .chars()
.filter(|character| !character.is_control()) .filter(|character| !character.is_control())
.collect(); .collect::<String>(),
);
self.value.insert_str(self.cursor, &text); self.value.insert_str(self.cursor, &text);
self.cursor += text.len(); self.cursor += text.len();
} }
pub(crate) fn paste_multiline(&mut self, value: &str) { pub(crate) fn paste_multiline(&mut self, value: &str) {
let text: String = value let text = Zeroizing::new(
value
.chars() .chars()
.map(|character| if character == '\t' { ' ' } else { character }) .map(|character| if character == '\t' { ' ' } else { character })
.filter(|character| *character == '\n' || !character.is_control()) .filter(|character| *character == '\n' || !character.is_control())
.collect(); .collect::<String>(),
);
self.value.insert_str(self.cursor, &text); self.value.insert_str(self.cursor, &text);
self.cursor += text.len(); self.cursor += text.len();
} }
@@ -1347,6 +1374,30 @@ impl Input {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn clearing_passwords_wipes_the_live_buffer_and_resets_unicode_cursor() {
for cancel in [true, false] {
let mut form = Form::default();
form.postgres[4].paste("secret-é界");
let length = form.postgres[4].value.len();
if cancel {
form.clear_password();
} else {
form.postgres[4].edit(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
}
let input = &mut form.postgres[4];
assert!(input.value.is_empty());
assert_eq!(input.position(), (1, 1));
assert!(input.value.capacity() >= length);
// The allocation is still owned, and zeroize initialized its former contents to zero.
let wiped = unsafe { std::slice::from_raw_parts(input.value.as_ptr(), length) };
assert!(wiped.iter().all(|byte| *byte == 0));
input.paste("next");
assert_eq!(input.value, "next");
assert_eq!(input.position(), (1, 5));
}
}
#[test] #[test]
fn sql_gutter_tracks_scrolling_digits_and_cursor_without_entering_sql() -> Result<()> { fn sql_gutter_tracks_scrolling_digits_and_cursor_without_entering_sql() -> Result<()> {
use ratatui::{Terminal, backend::TestBackend}; use ratatui::{Terminal, backend::TestBackend};
+2
View File
@@ -103,6 +103,8 @@ Navigation:
Left/Right changes TLS when focused. Enter connects and updates that saved entry Left/Right changes TLS when focused. Enter connects and updates that saved entry
only on success; failures keep the previous settings and connection. Esc discards edits. only on success; failures keep the previous settings and connection. Esc discards edits.
Passwords are masked, never saved, and clear on success or cancellation. Passwords are masked, never saved, and clear on success or cancellation.
Owned input buffers are zeroized on clear/drop; profile saving does not copy passwords.
This does not erase driver copies, prior input allocations, or protect a compromised host.
Ctrl-U clears a field; pasting is supported. Ctrl-U clears a field; pasting is supported.
Enter the database user explicitly; the operating-system username is not used. Enter the database user explicitly; the operating-system username is not used.
Configurations persist in $XDG_CONFIG_HOME/dbdash/connections.sqlite3, falling Configurations persist in $XDG_CONFIG_HOME/dbdash/connections.sqlite3, falling