From 59876fd134e5bcc78a41258d1cbe9b2c5be2dbd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Mon, 6 Jul 2026 18:05:32 -0600 Subject: [PATCH] Store the passphrase in a zeroizing buffer instead of a TextEdit Feed keystrokes directly into a zeroize::Zeroizing and draw a masked view, rather than an egui TextEdit whose retained widget state and undo history keep the plaintext around after the dialog closes. Move the value into the SecretString on submit (no clone; the buffer is zeroized on drop). Tests drive the field with synthetic input events since the masked view is no longer an accessible widget. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 90 +++++++++++++++++++++++++++++++---------------------- 3 files changed, 55 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acabcf0..cca9e0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2224,10 +2224,10 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" name = "pinentry-egui" version = "0.1.2" dependencies = [ - "accesskit", "eframe", "egui_kittest", "secrecy", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2ba81ad..150f82a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,11 @@ exclude = ["CLAUDE.md"] [dependencies] eframe = { version = "0.33", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } secrecy = "0.10" +zeroize = "1" [dev-dependencies] eframe = { version = "0.33", features = ["accesskit"] } egui_kittest = "0.33" -accesskit = "0.21" [profile.release] strip = true diff --git a/src/main.rs b/src/main.rs index 985d324..2c849d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use std::sync::mpsc; use eframe::egui; use secrecy::{ExposeSecret, SecretString}; +use zeroize::Zeroizing; fn percent_decode(s: &str) -> String { let bytes = s.as_bytes(); @@ -48,9 +49,11 @@ struct PinentryState { #[derive(Default)] struct PinDialogState { - password: String, + // Keystrokes are appended here directly (never through an egui `TextEdit`, + // whose retained widget state and undo history would keep the plaintext), and + // the buffer is zeroized on drop. Moved into the `SecretString` on submit. + password: Zeroizing, submitted: Option, // Some(true) = OK, Some(false) = Cancel - focus_set: bool, } fn pin_dialog_ui( @@ -89,20 +92,34 @@ fn pin_dialog_ui( }; ui.label(prompt); ui.add_space(4.0); - let response = ui.add_sized( - [ui.available_width(), 28.0], - egui::TextEdit::singleline(&mut dialog.password) - .password(true) - .hint_text("Enter passphrase") - .font(egui::TextStyle::Body), - ); - if !dialog.focus_set { - dialog.focus_set = true; - response.request_focus(); - } - if response.lost_focus() - && ui.input(|i| i.key_pressed(egui::Key::Enter)) - { + + // Feed keystrokes straight into the zeroizing buffer instead of an + // egui `TextEdit`, so the plaintext never enters egui's retained + // widget state or undo history. Only a masked view is drawn. + ui.input(|input| { + for event in &input.events { + match event { + egui::Event::Text(text) | egui::Event::Paste(text) => { + dialog.password.push_str(text); + } + egui::Event::Key { + key: egui::Key::Backspace, + pressed: true, + .. + } => { + dialog.password.pop(); + } + _ => {} + } + } + }); + let dots = "\u{2022}".repeat(dialog.password.chars().count()); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.add(egui::Label::new(egui::RichText::new(dots).monospace())); + }); + + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { dialog.submitted = Some(true); } ui.add_space(12.0); @@ -158,10 +175,10 @@ impl eframe::App for PinDialog { if let Some(ok) = self.dialog.submitted.take() { if ok { if self.want_pin { - let _ = self.tx.send(DialogResult::Pin(SecretString::from( - self.dialog.password.clone(), - ))); - self.dialog.password.clear(); + // Move the inner String out (leaving an empty one) into the + // secret, which zeroizes it in turn. + let value = std::mem::take(&mut *self.dialog.password); + let _ = self.tx.send(DialogResult::Pin(SecretString::from(value))); } else { let _ = self.tx.send(DialogResult::Confirmed); } @@ -322,7 +339,7 @@ fn main() { #[cfg(test)] mod tests { use super::*; - use egui_kittest::kittest::{Queryable, NodeT}; + use egui_kittest::kittest::Queryable; use egui_kittest::Harness; struct TestState { @@ -350,29 +367,30 @@ mod tests { ) } + // The custom masked field is not an accessible widget, so drive it with + // synthetic input events and assert on the resulting buffer/state. #[test] - fn test_password_field_gets_focus() { - let mut harness = make_harness("Enter your GPG passphrase", true); + fn test_type_password() { + let mut harness = make_harness("Enter passphrase", true); harness.run(); - let ti = harness.get_by_role(accesskit::Role::PasswordInput); - println!("focused: {}, value: {:?}", ti.is_focused(), ti.accesskit_node().value()); - assert!(ti.is_focused(), "password field should be auto-focused"); + harness.event(egui::Event::Text("secret123".into())); + harness.run(); + + assert_eq!(harness.state().dialog.password.as_str(), "secret123"); } #[test] - fn test_type_password() { + fn test_backspace_removes_last_char() { let mut harness = make_harness("Enter passphrase", true); harness.run(); - let ti = harness.get_by_role(accesskit::Role::PasswordInput); - assert!(ti.is_focused()); - - ti.type_text("secret123"); + harness.event(egui::Event::Text("abc".into())); + harness.run(); + harness.key_press(egui::Key::Backspace); harness.run(); - println!("password: {:?}", harness.state().dialog.password); - assert_eq!(harness.state().dialog.password, "secret123"); + assert_eq!(harness.state().dialog.password.as_str(), "ab"); } #[test] @@ -380,15 +398,13 @@ mod tests { let mut harness = make_harness("Enter passphrase", true); harness.run(); - let ti = harness.get_by_role(accesskit::Role::PasswordInput); - ti.type_text("mypass"); + harness.event(egui::Event::Text("mypass".into())); harness.run(); - harness.key_press(egui::Key::Enter); harness.run(); - println!("submitted: {:?}, password: {:?}", harness.state().dialog.submitted, harness.state().dialog.password); assert_eq!(harness.state().dialog.submitted, Some(true)); + assert_eq!(harness.state().dialog.password.as_str(), "mypass"); }