diff --git a/Cargo.lock b/Cargo.lock index 81ce186..11e4c78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d92245e..53d13d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/connection.rs b/src/connection.rs index baf2e0b..5226d64 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -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) -> 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::(), + ); 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::(), + ); 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}; diff --git a/src/main.rs b/src/main.rs index 1d47567..ca6c048 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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