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",
"sqlparser",
"tempfile",
"zeroize",
]
[[package]]
@@ -1719,3 +1720,9 @@ name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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"] }
shellexpand = { version = "3.1", features = ["path"] }
sqlparser = { version = "0.63", default-features = false, features = ["std"] }
zeroize = "1"
[dev-dependencies]
tempfile = "3"
+69 -18
View File
@@ -9,6 +9,7 @@ use ratatui::{
text::{Line, Span, Text},
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use zeroize::{Zeroize, Zeroizing};
use crate::{
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 => {
let text = Zeroizing::new(text);
form.error = None;
if let Some(input) = form.input_mut() {
input.paste(&text);
}
}
Event::Paste(text) if focus == Focus::Query => {
let text = Zeroizing::new(text);
if let Some(browser) = &mut browser {
browser.paste_query(&text);
}
}
Event::Paste(mut text) => text.zeroize(),
_ => {}
}
}
@@ -643,13 +647,25 @@ impl Profiles {
}
fn save(&mut self, form: &Form) -> Result<()> {
let mut saved = form.clone();
saved.clear_password();
saved.reconnect = false;
saved.error = None;
saved.name = Input::from(saved.name.value.trim());
let mut saved = Form {
id: form.id,
name: Input::from(form.name.value.trim()),
provider: form.provider,
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 {
form.postgres_config()?;
saved.postgres_config()?;
} else if form.path.value.is_empty() {
return Err("Enter a SQLite database file path.".into());
} else {
@@ -803,7 +819,8 @@ impl Form {
}
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) {
@@ -1076,6 +1093,12 @@ pub(crate) struct Input {
cursor: usize,
}
impl Drop for Input {
fn drop(&mut self) {
self.value.zeroize();
}
}
impl From<&str> for Input {
fn from(value: &str) -> Self {
Self {
@@ -1240,7 +1263,7 @@ impl Input {
self.value.remove(self.cursor);
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.value.clear();
self.value.zeroize();
self.cursor = 0;
}
KeyCode::Char(character)
@@ -1257,20 +1280,24 @@ impl Input {
}
fn paste(&mut self, value: &str) {
let text: String = value
.chars()
.filter(|character| !character.is_control())
.collect();
let text = Zeroizing::new(
value
.chars()
.filter(|character| !character.is_control())
.collect::<String>(),
);
self.value.insert_str(self.cursor, &text);
self.cursor += text.len();
}
pub(crate) fn paste_multiline(&mut self, value: &str) {
let text: String = value
.chars()
.map(|character| if character == '\t' { ' ' } else { character })
.filter(|character| *character == '\n' || !character.is_control())
.collect();
let text = Zeroizing::new(
value
.chars()
.map(|character| if character == '\t' { ' ' } else { character })
.filter(|character| *character == '\n' || !character.is_control())
.collect::<String>(),
);
self.value.insert_str(self.cursor, &text);
self.cursor += text.len();
}
@@ -1347,6 +1374,30 @@ impl Input {
#[cfg(test)]
mod tests {
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]
fn sql_gutter_tracks_scrolling_digits_and_cursor_without_entering_sql() -> Result<()> {
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
only on success; failures keep the previous settings and connection. Esc discards edits.
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.
Enter the database user explicitly; the operating-system username is not used.
Configurations persist in $XDG_CONFIG_HOME/dbdash/connections.sqlite3, falling