diff --git a/SECURITY.md b/SECURITY.md index 4e93074..ec69304 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -71,8 +71,13 @@ message and we will reply with it before sharing any sensitive details. replaces the previous frame's snapshot, so the typical lifecycle is "one frame on the heap, then overwritten on drop". Single-line and multiline are mutually exclusive with secure (passwords have no line - breaks); the text-input-v3 IME path is also skipped in secure mode so - preedit / commit strings never reach the compositor's IME stack. + breaks). Note: secure does **not** skip the text-input-v3 path — the + field still activates it (so the on-screen keyboard works on password + fields) but is flagged `Password` with `SensitiveData | HiddenText`, + which asks the IME / OSK not to predict, autocorrect or store the + value. The value can still reach a trusted compositor/IME; closing + that path would mean not activating text-input there (and losing the + OSK on the field). ### What `TextEdit::secure` does **not** cover diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index cfc327b..c8bd564 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -82,6 +82,9 @@ pub struct AppData pub current_cursor_shape: Option, pub text_input_manager: Option, pub text_input: Option, + /// Whether the currently focused text field is `secure` (password). + /// Drives the text-input-v3 content type. + pub text_input_secure: bool, /// `xdg-activation-v1`. Present when the compositor advertises the /// global. Used on first configure to honour an incoming /// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the diff --git a/src/event_loop/focus.rs b/src/event_loop/focus.rs index 4763d2b..edf7f10 100644 --- a/src/event_loop/focus.rs +++ b/src/event_loop/focus.rs @@ -84,6 +84,7 @@ impl AppData { let was_text_input; let is_text_input; + let secure; { let ss = self.surface_mut( focus ); @@ -91,6 +92,10 @@ impl AppData .and_then( |i| find_handlers( &ss.widget_rects, i ) ) .map( |h| h.is_text_input() ) .unwrap_or( false ); + secure = idx + .and_then( |i| find_handlers( &ss.widget_rects, i ) ) + .map( |h| matches!( h, WidgetHandlers::TextEdit { secure: true, .. } ) ) + .unwrap_or( false ); was_text_input = ss.focused_idx .and_then( |i| find_handlers( &ss.widget_rects, i ) ) .map( |h| h.is_text_input() ) @@ -141,17 +146,24 @@ impl AppData } } + eprintln!( "ltk: set_focus idx={:?} is_text_input={} was_text_input={}", idx, is_text_input, was_text_input ); if was_text_input && !is_text_input { self.deactivate_text_input(); self.app.on_text_input_focused( false ); self.dirty_caches(); } - if is_text_input && !was_text_input + else if is_text_input && !was_text_input { - self.activate_text_input( qh ); + self.activate_text_input( qh, secure ); self.app.on_text_input_focused( true ); self.dirty_caches(); } + else if is_text_input + { + // Focus moved between text fields: refresh the content type so a + // password field is flagged even without re-activating. + self.activate_text_input( qh, secure ); + } } } diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index 221228b..b5a6ec7 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -674,6 +674,10 @@ impl Dispatch for AppData ss.request_redraw(); } } + zwp_text_input_v3::Event::Enter { .. } => + { + state.reenable_text_input(); + } _ => {} } } diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index 107402f..bcbd521 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -277,6 +277,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> current_cursor_shape: None, text_input_manager, text_input: None, + text_input_secure: false, activation_state, activation_token_pending, data_device_manager, @@ -809,6 +810,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> } } } + eprintln!( "ltk: focus_request id={:?} resolved={}", id, hit.is_some() ); if let Some( ( focus, flat_idx ) ) = hit { let qh = data.qh.clone(); diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs index a17ecc2..5639cc1 100644 --- a/src/event_loop/text_editing/ime.rs +++ b/src/event_loop/text_editing/ime.rs @@ -13,21 +13,34 @@ use crate::widget::WidgetHandlers; impl AppData { - pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle ) + pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle, secure: bool ) { - if let ( Some( manager ), None ) = ( &self.text_input_manager, &self.text_input ) + self.text_input_secure = secure; + let ( hint, purpose ) = content_type( secure ); + match ( &self.text_input_manager, &self.text_input ) { - let seats: Vec<_> = self.seat_state.seats().collect(); - if let Some( seat ) = seats.into_iter().next() + ( Some( manager ), None ) => { - let ti = manager.get_text_input( &seat, qh, () ); - ti.enable(); - ti.set_content_type( - zwp_text_input_v3::ContentHint::None, - zwp_text_input_v3::ContentPurpose::Normal, - ); + let seats: Vec<_> = self.seat_state.seats().collect(); + if let Some( seat ) = seats.into_iter().next() + { + let ti = manager.get_text_input( &seat, qh, () ); + ti.enable(); + ti.set_content_type( hint, purpose ); + ti.commit(); + self.text_input = Some( ti ); + } else { + eprintln!( "ltk: activate_text_input: no seat available" ); + } + } + ( None, _ ) => + eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ), + // Focus moved between text fields: refresh the content type (e.g. + // flag a password field) without re-creating the object. + ( Some( _ ), Some( ti ) ) => + { + ti.set_content_type( hint, purpose ); ti.commit(); - self.text_input = Some( ti ); } } } @@ -42,6 +55,17 @@ impl AppData } } + pub( crate ) fn reenable_text_input( &self ) + { + if let Some( ti ) = &self.text_input + { + let ( hint, purpose ) = content_type( self.text_input_secure ); + ti.enable(); + ti.set_content_type( hint, purpose ); + ti.commit(); + } + } + /// Snapshot a focused-widget's geometry needed by the pointer /// hit-testers for text editing. Returns `None` when the widget /// isn't a TextEdit or its rect is missing — the helper above @@ -69,3 +93,19 @@ impl AppData Some( ( widget.rect, value, multiline, secure, align, font_size ) ) } } + +/// Map a field's `secure` flag to the text-input-v3 content type. Secure +/// fields are flagged `Password` with `SensitiveData | HiddenText` so the +/// IME/OSK skips prediction, autocorrect and storing the value. +fn content_type( secure: bool ) -> ( zwp_text_input_v3::ContentHint, zwp_text_input_v3::ContentPurpose ) +{ + if secure + { + ( + zwp_text_input_v3::ContentHint::SensitiveData | zwp_text_input_v3::ContentHint::HiddenText, + zwp_text_input_v3::ContentPurpose::Password, + ) + } else { + ( zwp_text_input_v3::ContentHint::None, zwp_text_input_v3::ContentPurpose::Normal ) + } +} diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 2e1366a..342788e 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -346,10 +346,14 @@ impl TextEdit /// the credential mount, and restrict core-dump capability with /// `prctl( PR_SET_DUMPABLE, 0 )` on the process. /// * **Compositor-side records.** Wayland text-input protocols can - /// surface preedit / commit strings to the compositor's IME - /// stack; `secure` skips text-input-v3 registration so this path - /// stays closed. Verify your compositor honours that — most do, - /// but the protocol does not strictly require it. + /// surface preedit / commit strings to the compositor's IME stack. + /// `secure` does *not* suppress text-input-v3 — the field still + /// registers so the on-screen keyboard activates on it — but it is + /// flagged `Password` with `SensitiveData | HiddenText`, asking the + /// IME / OSK to skip prediction, autocorrect and storing the value. + /// The value still reaches the (trusted) compositor/IME; for a + /// stricter threat model, suppress text-input on secure fields + /// instead (at the cost of losing the OSK there). /// /// See the in-repo `SECURITY.md` for the full threat-model write-up /// (the *Hardening features* section enumerates each guarantee and